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, bail_config};
12use std::collections::{HashMap, HashSet, VecDeque};
13
14pub mod directorysetting;
15use directorysetting::DirectorySetting;
16
17pub mod filesetting;
18use filesetting::FileSetting;
19
20pub mod gamesetting;
21use gamesetting::GameSettingType;
22
23pub mod genericsetting;
24use genericsetting::GenericSetting;
25
26pub mod encodingsetting;
27use encodingsetting::EncodingSetting;
28
29#[macro_use]
30pub mod error;
31#[macro_use]
32mod singletonsetting;
33mod strings;
34mod util;
35
36#[derive(Clone, Debug)]
43#[non_exhaustive]
44pub enum SettingValue {
45 DataDirectory(DirectorySetting),
47 GameSetting(GameSettingType),
49 UserData(DirectorySetting),
51 DataLocal(DirectorySetting),
53 Resources(DirectorySetting),
55 Encoding(EncodingSetting),
57 SubConfiguration(DirectorySetting),
59 Generic(GenericSetting),
61 ContentFile(FileSetting),
63 BethArchive(FileSetting),
65 Groundcover(FileSetting),
67}
68
69impl Display for SettingValue {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 let str = match self {
72 SettingValue::Encoding(encoding_setting) => encoding_setting.to_string(),
73 SettingValue::UserData(userdata_setting) => format!(
74 "{}user-data={}",
75 userdata_setting.meta().comment,
76 userdata_setting.original()
77 ),
78 SettingValue::DataLocal(data_local_setting) => format!(
79 "{}data-local={}",
80 data_local_setting.meta().comment,
81 data_local_setting.original(),
82 ),
83 SettingValue::Resources(resources_setting) => format!(
84 "{}resources={}",
85 resources_setting.meta().comment,
86 resources_setting.original()
87 ),
88 SettingValue::GameSetting(game_setting) => game_setting.to_string(),
89 SettingValue::DataDirectory(data_directory) => format!(
90 "{}data={}",
91 data_directory.meta().comment,
92 data_directory.original()
93 ),
94 SettingValue::SubConfiguration(sub_config) => format!(
95 "{}config={}",
96 sub_config.meta().comment,
97 sub_config.original()
98 ),
99 SettingValue::Generic(generic) => generic.to_string(),
100 SettingValue::ContentFile(plugin) => {
101 format!("{}content={}", plugin.meta().comment, plugin.value())
102 }
103 SettingValue::BethArchive(archive) => {
104 format!(
105 "{}fallback-archive={}",
106 archive.meta().comment,
107 archive.value(),
108 )
109 }
110 SettingValue::Groundcover(grass) => {
111 format!("{}groundcover={}", grass.meta().comment, grass.value())
112 }
113 };
114
115 writeln!(f, "{str}")
116 }
117}
118
119impl From<GameSettingType> for SettingValue {
120 fn from(g: GameSettingType) -> Self {
121 SettingValue::GameSetting(g)
122 }
123}
124
125impl From<DirectorySetting> for SettingValue {
126 fn from(d: DirectorySetting) -> Self {
127 SettingValue::DataDirectory(d)
128 }
129}
130
131impl SettingValue {
132 pub fn meta(&self) -> &crate::GameSettingMeta {
133 match self {
134 SettingValue::BethArchive(setting)
135 | SettingValue::Groundcover(setting)
136 | SettingValue::ContentFile(setting) => setting.meta(),
137 SettingValue::UserData(setting)
138 | SettingValue::DataLocal(setting)
139 | SettingValue::DataDirectory(setting)
140 | SettingValue::Resources(setting)
141 | SettingValue::SubConfiguration(setting) => setting.meta(),
142 SettingValue::GameSetting(setting) => setting.meta(),
143 SettingValue::Encoding(setting) => setting.meta(),
144 SettingValue::Generic(setting) => setting.meta(),
145 }
146 }
147
148 fn to_resolved_string(&self) -> Option<String> {
149 match self {
150 SettingValue::UserData(userdata_setting) => Some(format!(
151 "{}user-data={}\n",
152 userdata_setting.meta().comment,
153 userdata_setting.parsed().display()
154 )),
155 SettingValue::DataLocal(data_local_setting) => Some(format!(
156 "{}data-local={}\n",
157 data_local_setting.meta().comment,
158 data_local_setting.parsed().display()
159 )),
160 SettingValue::Resources(resources_setting) => Some(format!(
161 "{}resources={}\n",
162 resources_setting.meta().comment,
163 resources_setting.parsed().display()
164 )),
165 SettingValue::DataDirectory(data_directory) => Some(format!(
166 "{}data={}\n",
167 data_directory.meta().comment,
168 data_directory.parsed().display()
169 )),
170 SettingValue::SubConfiguration(_) => None,
171 SettingValue::Generic(generic) if generic.key().eq_ignore_ascii_case("replace") => None,
172 _ => Some(self.to_string()),
173 }
174 }
175}
176
177macro_rules! insert_dir_setting {
178 ($self:ident, $variant:ident, $value:expr, $config_file:expr, $comment:expr) => {{
179 $self
180 .settings
181 .push(SettingValue::$variant(DirectorySetting::new(
182 $value,
183 $config_file,
184 $comment,
185 )));
186 }};
187}
188
189#[derive(Debug, Default, Clone)]
196pub struct OpenMWConfiguration {
197 root_config: PathBuf,
198 settings: Vec<SettingValue>,
199 chain: Vec<ConfigChainEntry>,
200 indexed_content: HashSet<String>,
201 indexed_groundcover: HashSet<String>,
202 indexed_archives: HashSet<String>,
203 indexed_data_dirs: HashSet<PathBuf>,
204 indexed_game_setting_last: RefCell<HashMap<String, usize>>,
205 indexed_game_setting_order: RefCell<Vec<usize>>,
206 game_setting_indexes_dirty: Cell<bool>,
207}
208
209#[derive(Debug, Clone, Eq, PartialEq)]
210pub enum ConfigChainStatus {
211 Loaded,
212 SkippedMissing,
213}
214
215#[derive(Debug, Clone, Eq, PartialEq)]
216pub struct ConfigChainEntry {
217 path: PathBuf,
218 depth: usize,
219 status: ConfigChainStatus,
220}
221
222impl ConfigChainEntry {
223 #[must_use]
224 pub fn path(&self) -> &Path {
225 &self.path
226 }
227
228 #[must_use]
229 pub fn depth(&self) -> usize {
230 self.depth
231 }
232
233 #[must_use]
234 pub fn status(&self) -> &ConfigChainStatus {
235 &self.status
236 }
237}
238
239impl OpenMWConfiguration {
240 fn rebuild_indexes(&mut self) {
241 self.indexed_content.clear();
242 self.indexed_groundcover.clear();
243 self.indexed_archives.clear();
244 self.indexed_data_dirs.clear();
245
246 for setting in &self.settings {
247 match setting {
248 SettingValue::ContentFile(file) => {
249 self.indexed_content.insert(file.value().clone());
250 }
251 SettingValue::Groundcover(file) => {
252 self.indexed_groundcover.insert(file.value().clone());
253 }
254 SettingValue::BethArchive(file) => {
255 self.indexed_archives.insert(file.value().clone());
256 }
257 SettingValue::DataDirectory(dir) => {
258 self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
259 }
260 _ => {}
261 }
262 }
263
264 self.mark_game_setting_indexes_dirty();
265 }
266
267 fn mark_game_setting_indexes_dirty(&self) {
268 self.game_setting_indexes_dirty.set(true);
269 self.indexed_game_setting_last.borrow_mut().clear();
270 self.indexed_game_setting_order.borrow_mut().clear();
271 }
272
273 fn ensure_game_setting_indexes(&self) {
274 if !self.game_setting_indexes_dirty.get() {
275 return;
276 }
277
278 let mut last = HashMap::new();
279 for (index, setting) in self.settings.iter().enumerate() {
280 if let SettingValue::GameSetting(game_setting) = setting {
281 last.insert(game_setting.key().clone(), index);
282 }
283 }
284
285 let mut seen = HashSet::new();
286 let mut order = Vec::new();
287 for (index, setting) in self.settings.iter().enumerate().rev() {
288 if let SettingValue::GameSetting(game_setting) = setting
289 && seen.insert(game_setting.key())
290 {
291 order.push(index);
292 }
293 }
294
295 *self.indexed_game_setting_last.borrow_mut() = last;
296 *self.indexed_game_setting_order.borrow_mut() = order;
297 self.game_setting_indexes_dirty.set(false);
298 }
299
300 pub fn from_env() -> Result<Self, ConfigError> {
320 if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
321 let explicit_path = util::expand_leading_tilde(&explicit_path);
322
323 if explicit_path.as_os_str().is_empty() {
324 return Err(ConfigError::NotFileOrDirectory(explicit_path));
325 } else if explicit_path.is_absolute() {
326 return Self::new(Some(explicit_path));
327 } else if explicit_path.is_relative() {
328 return Self::new(Some(util::display_preserving_absolute(&explicit_path)?));
329 }
330 return Err(ConfigError::NotFileOrDirectory(explicit_path));
331 } else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
332 let path_list = if cfg!(windows) {
333 path_list.split(';')
334 } else {
335 path_list.split(':')
336 };
337
338 for dir in path_list {
339 let dir = util::expand_leading_tilde(dir);
340 let config_file = dir.join("openmw.cfg");
341
342 if config_file.is_file() {
343 return Self::new(Some(dir));
344 }
345 }
346 }
347
348 let root_config = crate::try_default_root_config_path()?;
349 Self::new(Some(root_config))
350 }
351
352 pub fn from_env_or_user_config() -> Result<Self, ConfigError> {
367 match Self::from_env() {
368 Ok(config) => Ok(config),
369 Err(ConfigError::CannotFindRootConfig { local, global }) => {
370 let user = crate::try_default_user_config_file()?;
371
372 if user.is_file() {
373 Self::new(Some(user))
374 } else {
375 Err(ConfigError::CannotFindAnyConfig {
376 local,
377 global,
378 user,
379 })
380 }
381 }
382 Err(error) => Err(error),
383 }
384 }
385
386 pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
402 let mut config = OpenMWConfiguration::default();
403 let root_config = match path {
404 Some(path) => util::input_config_path(path)?,
405 None => crate::try_default_config_path()?.join("openmw.cfg"),
406 };
407
408 config.root_config = root_config;
409
410 if let Err(error) = config.load(&config.root_config.clone()) {
411 Err(error)
412 } else {
413 if let Some(dir) = config.data_local() {
414 let path = dir.parsed();
415
416 let path_meta = metadata(path);
417 if path_meta.is_err()
418 && let Err(error) = create_dir_all(path)
419 {
420 util::debug_log(&format!(
421 "WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
422 path.display()
423 ));
424 }
425
426 let mut synthetic_data_dir = dir.clone();
427 synthetic_data_dir.meta.source_config = PathBuf::new();
432 synthetic_data_dir.meta.comment.clear();
433
434 config
435 .settings
436 .push(SettingValue::DataDirectory(synthetic_data_dir));
437 }
438
439 if let Some(setting) = config.resources() {
440 let dir = setting.parsed();
441
442 let engine_vfs = DirectorySetting::new(
443 dir.join("vfs").to_string_lossy().to_string(),
444 setting.meta.source_config.clone(),
445 &mut setting.meta.comment.clone(),
446 );
447
448 config
449 .settings
450 .insert(0, SettingValue::DataDirectory(engine_vfs));
451 }
452
453 util::debug_log(&format!("{:#?}", config.settings));
454
455 Ok(config)
456 }
457 }
458
459 pub fn new_empty(user_config_dir: impl Into<PathBuf>) -> Result<Self, ConfigError> {
470 let mut user_config_dir = user_config_dir.into();
471
472 if user_config_dir.as_os_str().is_empty()
473 || user_config_dir
474 .file_name()
475 .is_some_and(|file_name| file_name == "openmw.cfg")
476 {
477 return Err(ConfigError::NotFileOrDirectory(user_config_dir));
478 }
479
480 if user_config_dir.is_relative() {
481 user_config_dir = util::display_preserving_absolute(&user_config_dir)?;
482 }
483
484 Ok(Self {
485 root_config: user_config_dir.join("openmw.cfg"),
486 ..Self::default()
487 })
488 }
489
490 pub fn load_optional(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
501 let path = path.into();
502
503 if path.as_os_str().is_empty() {
504 return Err(ConfigError::NotFileOrDirectory(path));
505 }
506
507 if path.is_dir() && !path.join("openmw.cfg").exists() {
508 return Self::new_empty(path);
509 }
510
511 if path.exists() {
512 return Self::new(Some(path));
513 }
514
515 if path
516 .file_name()
517 .is_some_and(|file_name| file_name == "openmw.cfg")
518 {
519 let parent = path
520 .parent()
521 .filter(|parent| !parent.as_os_str().is_empty())
522 .unwrap_or_else(|| Path::new("."));
523 return Self::new_empty(parent);
524 }
525
526 Self::new_empty(path)
527 }
528
529 #[must_use]
538 pub fn root_config_file(&self) -> &std::path::Path {
539 &self.root_config
540 }
541
542 #[must_use]
549 pub fn root_config_dir(&self) -> PathBuf {
550 self.root_config
551 .parent()
552 .expect("root_config has no parent directory")
553 .to_path_buf()
554 }
555
556 #[must_use]
557 pub fn is_user_config(&self) -> bool {
558 util::paths_equivalent(&self.root_config_dir(), &self.user_config_path())
559 }
560
561 pub fn user_config(self) -> Result<Self, ConfigError> {
564 let user_path = self.user_config_path();
565 if util::paths_equivalent(&self.root_config_dir(), &user_path) {
566 Ok(self)
567 } else {
568 Self::new(Some(user_path))
569 }
570 }
571
572 pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
575 let user_path = self.user_config_path();
576 if util::paths_equivalent(&self.root_config_dir(), &user_path) {
577 Ok(self.clone())
578 } else {
579 Self::new(Some(user_path))
580 }
581 }
582
583 #[must_use]
599 pub fn user_config_path(&self) -> PathBuf {
600 self.sub_configs()
601 .map(|setting| setting.parsed().to_path_buf())
602 .last()
603 .unwrap_or_else(|| self.root_config_dir())
604 }
605
606 impl_singleton_setting! {
607 UserData => {
608 get: userdata,
609 set: set_userdata,
610 in_type: DirectorySetting
611 },
612 Resources => {
613 get: resources,
614 set: set_resources,
615 in_type: DirectorySetting
616 },
617 DataLocal => {
618 get: data_local,
619 set: set_data_local,
620 in_type: DirectorySetting
621 },
622 Encoding => {
623 get: encoding,
624 set: set_encoding,
625 in_type: EncodingSetting
626 }
627 }
628
629 fn directory_setting_for_user_config(&self, path: impl AsRef<Path>) -> DirectorySetting {
630 DirectorySetting::new(
631 path.as_ref().to_string_lossy(),
632 self.user_config_path().join("openmw.cfg"),
633 &mut String::default(),
634 )
635 }
636
637 pub fn set_data_local_path(&mut self, path: impl AsRef<Path>) {
642 let setting = self.directory_setting_for_user_config(path);
643 self.set_data_local(Some(setting));
644 }
645
646 pub fn set_resources_path(&mut self, path: impl AsRef<Path>) {
651 let setting = self.directory_setting_for_user_config(path);
652 self.set_resources(Some(setting));
653 }
654
655 pub fn set_user_data_path(&mut self, path: impl AsRef<Path>) {
659 let setting = self.directory_setting_for_user_config(path);
660 self.set_userdata(Some(setting));
661 }
662
663 pub fn clear_data_local(&mut self) {
665 self.set_data_local(None);
666 }
667
668 pub fn clear_resources(&mut self) {
670 self.set_resources(None);
671 }
672
673 pub fn clear_user_data(&mut self) {
675 self.set_userdata(None);
676 }
677
678 pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
682 self.settings.iter().filter_map(|setting| match setting {
683 SettingValue::ContentFile(plugin) => Some(plugin),
684 _ => None,
685 })
686 }
687
688 #[must_use]
690 pub fn has_content_file(&self, file_name: &str) -> bool {
691 self.indexed_content.contains(file_name)
692 }
693
694 #[must_use]
696 pub fn has_groundcover_file(&self, file_name: &str) -> bool {
697 self.indexed_groundcover.contains(file_name)
698 }
699
700 #[must_use]
702 pub fn has_archive_file(&self, file_name: &str) -> bool {
703 self.indexed_archives.contains(file_name)
704 }
705
706 #[must_use]
711 pub fn has_data_dir(&self, file_name: &str) -> bool {
712 let query = if file_name.contains(['/', '\\']) {
713 PathBuf::from(file_name.replace(['/', '\\'], std::path::MAIN_SEPARATOR_STR))
714 } else {
715 PathBuf::from(file_name)
716 };
717 self.indexed_data_dirs.contains(&query)
718 }
719
720 pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
723 let duplicate = self.settings.iter().find_map(|setting| match setting {
724 SettingValue::ContentFile(plugin) => {
725 if plugin.value() == content_file {
726 Some(plugin)
727 } else {
728 None
729 }
730 }
731 _ => None,
732 });
733
734 if let Some(duplicate) = duplicate {
735 bail_config!(
736 content_already_defined,
737 duplicate.value().to_owned(),
738 duplicate.meta().source_config
739 )
740 }
741
742 self.settings
743 .push(SettingValue::ContentFile(FileSetting::new(
744 content_file,
745 &self.user_config_path().join("openmw.cfg"),
746 &mut String::default(),
747 )));
748 self.rebuild_indexes();
749
750 Ok(())
751 }
752
753 pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
755 self.settings.iter().filter_map(|setting| match setting {
756 SettingValue::Groundcover(grass) => Some(grass),
757 _ => None,
758 })
759 }
760
761 pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
764 let duplicate = self.settings.iter().find_map(|setting| match setting {
765 SettingValue::Groundcover(plugin) => {
766 if plugin.value() == content_file {
767 Some(plugin)
768 } else {
769 None
770 }
771 }
772 _ => None,
773 });
774
775 if let Some(duplicate) = duplicate {
776 bail_config!(
777 groundcover_already_defined,
778 duplicate.value().to_owned(),
779 duplicate.meta().source_config
780 )
781 }
782
783 self.settings
784 .push(SettingValue::Groundcover(FileSetting::new(
785 content_file,
786 &self.user_config_path().join("openmw.cfg"),
787 &mut String::default(),
788 )));
789 self.rebuild_indexes();
790
791 Ok(())
792 }
793
794 pub fn remove_content_file(&mut self, file_name: &str) {
796 self.clear_matching_internal(|setting| match setting {
797 SettingValue::ContentFile(existing_file) => existing_file == file_name,
798 _ => false,
799 });
800 self.rebuild_indexes();
801 }
802
803 pub fn remove_groundcover_file(&mut self, file_name: &str) {
805 self.clear_matching_internal(|setting| match setting {
806 SettingValue::Groundcover(existing_file) => existing_file == file_name,
807 _ => false,
808 });
809 self.rebuild_indexes();
810 }
811
812 pub fn remove_archive_file(&mut self, file_name: &str) {
814 self.clear_matching_internal(|setting| match setting {
815 SettingValue::BethArchive(existing_file) => existing_file == file_name,
816 _ => false,
817 });
818 self.rebuild_indexes();
819 }
820
821 pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
823 self.clear_matching_internal(|setting| match setting {
824 SettingValue::DataDirectory(existing_data_dir) => {
825 existing_data_dir.parsed() == data_dir
826 || existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
827 }
828 _ => false,
829 });
830 self.rebuild_indexes();
831 }
832
833 pub fn add_data_directory(&mut self, dir: &Path) {
835 self.settings
836 .push(SettingValue::DataDirectory(DirectorySetting::new(
837 dir.to_string_lossy(),
838 self.user_config_path().join("openmw.cfg"),
839 &mut String::default(),
840 )));
841 self.rebuild_indexes();
842 }
843
844 pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
847 let duplicate = self.settings.iter().find_map(|setting| match setting {
848 SettingValue::BethArchive(archive) => {
849 if archive.value() == archive_file {
850 Some(archive)
851 } else {
852 None
853 }
854 }
855 _ => None,
856 });
857
858 if let Some(duplicate) = duplicate {
859 bail_config!(
860 duplicate_archive_file,
861 duplicate.value().to_owned(),
862 duplicate.meta().source_config
863 )
864 }
865
866 self.settings
867 .push(SettingValue::BethArchive(FileSetting::new(
868 archive_file,
869 &self.user_config_path().join("openmw.cfg"),
870 &mut String::default(),
871 )));
872 self.rebuild_indexes();
873
874 Ok(())
875 }
876
877 pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
879 self.settings.iter().filter_map(|setting| match setting {
880 SettingValue::BethArchive(archive) => Some(archive),
881 _ => None,
882 })
883 }
884
885 pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
889 self.clear_matching_internal(|setting| matches!(setting, SettingValue::ContentFile(_)));
890
891 if let Some(plugins) = plugins {
892 let cfg_path = self.user_config_path().join("openmw.cfg");
893 let mut empty = String::default();
894 for plugin in plugins {
895 self.settings
896 .push(SettingValue::ContentFile(FileSetting::new(
897 &plugin, &cfg_path, &mut empty,
898 )));
899 }
900 }
901
902 self.rebuild_indexes();
903 }
904
905 pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
909 self.clear_matching_internal(|setting| matches!(setting, SettingValue::BethArchive(_)));
910
911 if let Some(archives) = archives {
912 let cfg_path = self.user_config_path().join("openmw.cfg");
913 let mut empty = String::default();
914 for archive in archives {
915 self.settings
916 .push(SettingValue::BethArchive(FileSetting::new(
917 &archive, &cfg_path, &mut empty,
918 )));
919 }
920 }
921
922 self.rebuild_indexes();
923 }
924
925 pub fn generic_settings_iter(&self) -> impl Iterator<Item = &GenericSetting> {
927 self.settings.iter().filter_map(|setting| match setting {
928 SettingValue::Generic(generic) => Some(generic),
929 _ => None,
930 })
931 }
932
933 pub fn set_generic_settings(&mut self, key: &str, values: Option<Vec<String>>) {
937 self.clear_matching_internal(|setting| match setting {
938 SettingValue::Generic(generic) => generic.key() == key,
939 _ => false,
940 });
941
942 if let Some(values) = values {
943 let cfg_path = self.user_config_path().join("openmw.cfg");
944 let mut empty = String::default();
945
946 for value in values {
947 self.settings
948 .push(SettingValue::Generic(GenericSetting::new(
949 key, &value, &cfg_path, &mut empty,
950 )));
951 }
952 }
953
954 self.rebuild_indexes();
955 }
956
957 pub fn add_generic_setting(&mut self, key: &str, value: &str) {
959 self.settings
960 .push(SettingValue::Generic(GenericSetting::new(
961 key,
962 value,
963 &self.user_config_path().join("openmw.cfg"),
964 &mut String::default(),
965 )));
966 self.rebuild_indexes();
967 }
968
969 pub fn settings_matching<'a, P>(
971 &'a self,
972 predicate: P,
973 ) -> impl Iterator<Item = &'a SettingValue>
974 where
975 P: Fn(&SettingValue) -> bool + 'a,
976 {
977 self.settings.iter().filter(move |s| predicate(s))
978 }
979
980 fn clear_matching_internal<P>(&mut self, predicate: P)
982 where
983 P: Fn(&SettingValue) -> bool,
984 {
985 self.settings.retain(|s| !predicate(s));
986 }
987
988 fn is_synthetic_data_local_data_directory(&self, setting: &SettingValue) -> bool {
989 let SettingValue::DataDirectory(data_dir) = setting else {
990 return false;
991 };
992
993 if !data_dir.meta().source_config.as_os_str().is_empty() {
994 return false;
995 }
996
997 self.data_local().is_some_and(|data_local| {
998 data_dir.parsed() == data_local.parsed()
999 || data_dir.original_str() == data_local.original_str()
1000 })
1001 }
1002
1003 pub fn clear_matching<P>(&mut self, predicate: P)
1005 where
1006 P: Fn(&SettingValue) -> bool,
1007 {
1008 self.clear_matching_internal(predicate);
1009 self.rebuild_indexes();
1010 }
1011
1012 pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
1016 self.clear_matching_internal(|setting| matches!(setting, SettingValue::DataDirectory(_)));
1017
1018 if let Some(dirs) = dirs {
1019 let cfg_path = self.user_config_path().join("openmw.cfg");
1020 let mut empty = String::default();
1021
1022 for dir in dirs {
1023 self.settings
1024 .push(SettingValue::DataDirectory(DirectorySetting::new(
1025 dir.to_string_lossy(),
1026 cfg_path.clone(),
1027 &mut empty,
1028 )));
1029 }
1030 }
1031
1032 self.rebuild_indexes();
1033 }
1034
1035 pub fn set_game_setting(
1042 &mut self,
1043 base_value: &str,
1044 config_path: Option<PathBuf>,
1045 comment: &mut String,
1046 ) -> Result<(), ConfigError> {
1047 let new_setting = GameSettingType::try_from((
1048 base_value.to_owned(),
1049 config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
1050 comment,
1051 ))?;
1052
1053 self.settings.push(SettingValue::GameSetting(new_setting));
1054 self.rebuild_indexes();
1055
1056 Ok(())
1057 }
1058
1059 pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
1067 self.clear_matching_internal(|setting| matches!(setting, SettingValue::GameSetting(_)));
1068
1069 if let Some(settings) = settings {
1070 let cfg_path = self.user_config_path().join("openmw.cfg");
1071 let mut empty = String::default();
1072
1073 for setting in settings {
1074 let parsed =
1075 match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
1076 Ok(parsed) => parsed,
1077 Err(error) => {
1078 self.rebuild_indexes();
1079 return Err(error);
1080 }
1081 };
1082
1083 self.settings.push(SettingValue::GameSetting(parsed));
1084 }
1085 }
1086
1087 self.rebuild_indexes();
1088
1089 Ok(())
1090 }
1091
1092 pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
1097 self.settings.iter().filter_map(|setting| match setting {
1098 SettingValue::SubConfiguration(subconfig) => Some(subconfig),
1099 _ => None,
1100 })
1101 }
1102
1103 pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
1108 self.chain.iter()
1109 }
1110
1111 pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
1128 self.ensure_game_setting_indexes();
1129 let order = self.indexed_game_setting_order.borrow().clone();
1130 order
1131 .into_iter()
1132 .filter_map(move |index| match &self.settings[index] {
1133 SettingValue::GameSetting(setting) => Some(setting),
1134 _ => None,
1135 })
1136 }
1137
1138 #[must_use]
1142 pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
1143 self.ensure_game_setting_indexes();
1144 self.indexed_game_setting_last
1145 .borrow()
1146 .get(key)
1147 .and_then(|index| match &self.settings[*index] {
1148 SettingValue::GameSetting(setting) => Some(setting),
1149 _ => None,
1150 })
1151 }
1152
1153 pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
1162 self.settings.iter().filter_map(|setting| match setting {
1163 SettingValue::DataDirectory(data_dir) => Some(data_dir),
1164 _ => None,
1165 })
1166 }
1167
1168 const MAX_CONFIG_DEPTH: usize = 16;
1169
1170 #[allow(clippy::too_many_lines)]
1171 fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
1172 let mut pending_configs = VecDeque::new();
1173 pending_configs.push_back((root_config.to_path_buf(), 0usize));
1174
1175 let mut seen_content: HashSet<String> = self
1176 .settings
1177 .iter()
1178 .filter_map(|setting| match setting {
1179 SettingValue::ContentFile(file) => Some(file.value().clone()),
1180 _ => None,
1181 })
1182 .collect();
1183 let mut seen_groundcover: HashSet<String> = self
1184 .settings
1185 .iter()
1186 .filter_map(|setting| match setting {
1187 SettingValue::Groundcover(file) => Some(file.value().clone()),
1188 _ => None,
1189 })
1190 .collect();
1191 let mut seen_archives: HashSet<String> = self
1192 .settings
1193 .iter()
1194 .filter_map(|setting| match setting {
1195 SettingValue::BethArchive(file) => Some(file.value().clone()),
1196 _ => None,
1197 })
1198 .collect();
1199
1200 while let Some((config_dir, depth)) = pending_configs.pop_front() {
1201 if depth > Self::MAX_CONFIG_DEPTH {
1202 bail_config!(max_depth_exceeded, config_dir);
1203 }
1204
1205 util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", config_dir.display()));
1206
1207 if !config_dir.exists() {
1208 bail_config!(cannot_find, config_dir);
1209 }
1210
1211 let cfg_file_path = if config_dir.is_dir() {
1212 config_dir.join("openmw.cfg")
1213 } else {
1214 config_dir
1215 };
1216
1217 self.chain.push(ConfigChainEntry {
1218 path: cfg_file_path.clone(),
1219 depth,
1220 status: ConfigChainStatus::Loaded,
1221 });
1222
1223 let lines = read_to_string(&cfg_file_path)?;
1224
1225 let mut queued_comment = String::new();
1226 let mut sub_configs: Vec<(String, String)> = Vec::new();
1227
1228 for (index, line) in lines.lines().enumerate() {
1229 let line_no = index + 1;
1230 let trimmed = line.trim();
1231
1232 if trimmed.is_empty() {
1233 queued_comment.push('\n');
1234 continue;
1235 } else if trimmed.starts_with('#') {
1236 queued_comment.push_str(line);
1237 queued_comment.push('\n');
1238 continue;
1239 }
1240
1241 let Some((key, value)) = trimmed.split_once('=') else {
1242 bail_config!(invalid_line, trimmed.into(), cfg_file_path.clone(), line_no);
1243 };
1244
1245 let key = key.trim();
1246 let value = value.trim();
1247
1248 match key {
1249 "content" => {
1250 if !seen_content.insert(value.to_owned()) {
1251 bail_config!(
1252 duplicate_content_file,
1253 value.to_owned(),
1254 cfg_file_path,
1255 line_no
1256 );
1257 }
1258 self.settings
1259 .push(SettingValue::ContentFile(FileSetting::new(
1260 value,
1261 &cfg_file_path,
1262 &mut queued_comment,
1263 )));
1264 }
1265 "groundcover" => {
1266 if !seen_groundcover.insert(value.to_owned()) {
1267 bail_config!(
1268 duplicate_groundcover_file,
1269 value.to_owned(),
1270 cfg_file_path,
1271 line_no
1272 );
1273 }
1274 self.settings
1275 .push(SettingValue::Groundcover(FileSetting::new(
1276 value,
1277 &cfg_file_path,
1278 &mut queued_comment,
1279 )));
1280 }
1281 "fallback-archive" => {
1282 if !seen_archives.insert(value.to_owned()) {
1283 bail_config!(
1284 duplicate_archive_file,
1285 value.to_owned(),
1286 cfg_file_path,
1287 line_no
1288 );
1289 }
1290 self.settings
1291 .push(SettingValue::BethArchive(FileSetting::new(
1292 value,
1293 &cfg_file_path,
1294 &mut queued_comment,
1295 )));
1296 }
1297 "fallback" => {
1298 let game_setting = GameSettingType::try_from((
1299 value.to_owned(),
1300 cfg_file_path.clone(),
1301 &mut queued_comment,
1302 ))
1303 .map_err(|error| match error {
1304 ConfigError::InvalidGameSetting {
1305 value, config_path, ..
1306 } => ConfigError::InvalidGameSetting {
1307 value,
1308 config_path,
1309 line: Some(line_no),
1310 },
1311 _ => error,
1312 })?;
1313
1314 self.settings.push(SettingValue::GameSetting(game_setting));
1315 }
1316 "encoding" => {
1317 let encoding = EncodingSetting::try_from((
1318 value.to_owned(),
1319 &cfg_file_path,
1320 &mut queued_comment,
1321 ))
1322 .map_err(|error| match error {
1323 ConfigError::BadEncoding {
1324 value, config_path, ..
1325 } => ConfigError::BadEncoding {
1326 value,
1327 config_path,
1328 line: Some(line_no),
1329 },
1330 _ => error,
1331 })?;
1332 self.set_encoding(Some(encoding));
1333 }
1334 "config" => {
1335 sub_configs.push((value.to_owned(), std::mem::take(&mut queued_comment)));
1336 }
1337 "data" => {
1338 insert_dir_setting!(
1339 self,
1340 DataDirectory,
1341 value,
1342 cfg_file_path.clone(),
1343 &mut queued_comment
1344 );
1345 }
1346 "resources" => {
1347 insert_dir_setting!(
1348 self,
1349 Resources,
1350 value,
1351 cfg_file_path.clone(),
1352 &mut queued_comment
1353 );
1354 }
1355 "user-data" => {
1356 insert_dir_setting!(
1357 self,
1358 UserData,
1359 value,
1360 cfg_file_path.clone(),
1361 &mut queued_comment
1362 );
1363 }
1364 "data-local" => {
1365 insert_dir_setting!(
1366 self,
1367 DataLocal,
1368 value,
1369 cfg_file_path.clone(),
1370 &mut queued_comment
1371 );
1372 }
1373 "replace" => match value.to_ascii_lowercase().as_str() {
1374 "content" => {
1375 self.clear_matching_internal(|s| {
1376 matches!(s, SettingValue::ContentFile(_))
1377 });
1378 seen_content.clear();
1379 }
1380 "data" => {
1381 self.clear_matching_internal(|s| {
1382 matches!(s, SettingValue::DataDirectory(_))
1383 });
1384 }
1385 "fallback" => {
1386 self.clear_matching_internal(|s| {
1387 matches!(s, SettingValue::GameSetting(_))
1388 });
1389 }
1390 "fallback-archives" => {
1391 self.clear_matching_internal(|s| {
1392 matches!(s, SettingValue::BethArchive(_))
1393 });
1394 seen_archives.clear();
1395 }
1396 "groundcover" => {
1397 self.clear_matching_internal(|s| {
1398 matches!(s, SettingValue::Groundcover(_))
1399 });
1400 seen_groundcover.clear();
1401 }
1402 "data-local" => self.set_data_local(None),
1403 "resources" => self.set_resources(None),
1404 "user-data" => self.set_userdata(None),
1405 "config" => {
1406 self.settings.clear();
1407 seen_content.clear();
1408 seen_groundcover.clear();
1409 seen_archives.clear();
1410 sub_configs.clear();
1411 pending_configs.clear();
1412 }
1413 _ => {}
1414 },
1415 _ => {
1416 let setting =
1417 GenericSetting::new(key, value, &cfg_file_path, &mut queued_comment);
1418 self.settings.push(SettingValue::Generic(setting));
1419 }
1420 }
1421 }
1422
1423 for (subconfig_path, mut subconfig_comment) in sub_configs {
1424 let mut comment = std::mem::take(&mut subconfig_comment);
1425 let setting =
1426 DirectorySetting::new(subconfig_path, cfg_file_path.clone(), &mut comment);
1427 let subconfig_file = setting.parsed().join("openmw.cfg");
1428
1429 if std::fs::metadata(&subconfig_file).is_ok() {
1430 self.settings.push(SettingValue::SubConfiguration(setting));
1431 pending_configs.push_back((subconfig_file, depth + 1));
1432 } else {
1433 self.chain.push(ConfigChainEntry {
1434 path: subconfig_file,
1435 depth: depth + 1,
1436 status: ConfigChainStatus::SkippedMissing,
1437 });
1438 util::debug_log_lazy(|| {
1439 format!(
1440 "Skipping parsing of {} as this directory does not actually contain an openmw.cfg!",
1441 setting.parsed().display(),
1442 )
1443 });
1444 }
1445 }
1446 }
1447
1448 self.rebuild_indexes();
1449
1450 Ok(())
1451 }
1452
1453 fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1454 use std::io::Write;
1455 use std::time::{SystemTime, UNIX_EPOCH};
1456
1457 let parent = path
1458 .parent()
1459 .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1460
1461 let nonce = SystemTime::now()
1462 .duration_since(UNIX_EPOCH)
1463 .map_or(0, |d| d.as_nanos());
1464 let tmp_path = parent.join(format!(
1465 ".openmw-config-tmp-{}-{}",
1466 std::process::id(),
1467 nonce
1468 ));
1469
1470 let mut file = std::fs::OpenOptions::new()
1471 .write(true)
1472 .create_new(true)
1473 .open(&tmp_path)?;
1474
1475 file.write_all(config_string.as_bytes())?;
1476 file.sync_all()?;
1477
1478 #[cfg(windows)]
1479 {
1480 if path.exists() {
1481 if let Ok(metadata) = std::fs::metadata(path) {
1482 let mut permissions = metadata.permissions();
1483 if permissions.readonly() {
1484 permissions.set_readonly(false);
1487 std::fs::set_permissions(path, permissions)?;
1488 }
1489 }
1490
1491 std::fs::remove_file(path)?;
1492 }
1493 }
1494
1495 std::fs::rename(&tmp_path, path)?;
1496
1497 Ok(())
1498 }
1499
1500 fn save_string_to_path(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1501 let parent = path
1502 .parent()
1503 .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1504 let writable_probe = parent.join(".openmw-config-write-test");
1505
1506 if !util::is_writable(&writable_probe) {
1507 bail_config!(not_writable, parent);
1508 }
1509
1510 Self::write_config(config_string, path)
1511 }
1512
1513 #[must_use]
1523 pub fn to_resolved_string(&self) -> String {
1524 use std::fmt::Write as _;
1525
1526 let mut config_string = String::new();
1527
1528 for setting in &self.settings {
1529 if self.is_synthetic_data_local_data_directory(setting) {
1530 continue;
1531 }
1532
1533 if let Some(setting_string) = setting.to_resolved_string() {
1534 config_string.push_str(&setting_string);
1535 }
1536 }
1537
1538 writeln!(
1539 config_string,
1540 "# OpenMW-Config Serializer Version: {}",
1541 env!("CARGO_PKG_VERSION")
1542 )
1543 .expect("writing to a String cannot fail");
1544
1545 config_string
1546 }
1547
1548 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1557 Self::save_string_to_path(&self.to_string(), path.as_ref())
1558 }
1559
1560 pub fn save_resolved_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1570 Self::save_string_to_path(&self.to_resolved_string(), path.as_ref())
1571 }
1572
1573 pub fn save_user(&self) -> Result<(), ConfigError> {
1583 let target_dir = self.user_config_path();
1584 let cfg_path = target_dir.join("openmw.cfg");
1585
1586 std::fs::create_dir_all(&target_dir)?;
1587
1588 if !util::is_writable(&cfg_path) {
1589 bail_config!(not_writable, &cfg_path);
1590 }
1591
1592 let mut user_settings_string = String::new();
1593
1594 for user_setting in self.settings_matching(|setting| {
1595 util::paths_equivalent(&setting.meta().source_config, &cfg_path)
1596 }) {
1597 if self.is_synthetic_data_local_data_directory(user_setting) {
1598 continue;
1599 }
1600
1601 user_settings_string.push_str(&user_setting.to_string());
1602 }
1603
1604 Self::write_config(&user_settings_string, &cfg_path)?;
1605
1606 Ok(())
1607 }
1608
1609 pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
1619 let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
1620 SettingValue::SubConfiguration(subconfig) => {
1621 util::paths_equivalent(subconfig.parsed(), target_dir)
1622 || subconfig.original() == target_dir.to_string_lossy().as_ref()
1623 }
1624 _ => false,
1625 });
1626
1627 if !subconfig_is_loaded {
1628 bail_config!(subconfig_not_loaded, target_dir);
1629 }
1630
1631 let cfg_path = target_dir.join("openmw.cfg");
1632
1633 if !util::is_writable(&cfg_path) {
1634 bail_config!(not_writable, &cfg_path);
1635 }
1636
1637 let mut subconfig_settings_string = String::new();
1638
1639 for subconfig_setting in self.settings_matching(|setting| {
1640 util::paths_equivalent(&setting.meta().source_config, &cfg_path)
1641 }) {
1642 if self.is_synthetic_data_local_data_directory(subconfig_setting) {
1643 continue;
1644 }
1645
1646 subconfig_settings_string.push_str(&subconfig_setting.to_string());
1647 }
1648
1649 Self::write_config(&subconfig_settings_string, &cfg_path)?;
1650
1651 Ok(())
1652 }
1653}
1654
1655impl fmt::Display for OpenMWConfiguration {
1666 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1667 self.settings
1668 .iter()
1669 .filter(|setting| !self.is_synthetic_data_local_data_directory(setting))
1670 .try_for_each(|setting| write!(f, "{setting}"))?;
1671
1672 writeln!(
1673 f,
1674 "# OpenMW-Config Serializer Version: {}",
1675 env!("CARGO_PKG_VERSION")
1676 )?;
1677
1678 Ok(())
1679 }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684 use super::*;
1685 use std::io::Write;
1686 use std::sync::atomic::{AtomicU64, Ordering};
1687
1688 fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
1693 let cfg = dir.join("openmw.cfg");
1694 let mut f = std::fs::File::create(&cfg).unwrap();
1695 f.write_all(contents.as_bytes()).unwrap();
1696 cfg
1697 }
1698
1699 fn temp_dir() -> PathBuf {
1700 static COUNTER: AtomicU64 = AtomicU64::new(0);
1705 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1706 let base =
1707 std::env::temp_dir().join(format!("openmw_cfg_test_{}_{id}", std::process::id()));
1708 std::fs::create_dir_all(&base).unwrap();
1709 base
1710 }
1711
1712 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1713 crate::test_env_lock()
1714 }
1715
1716 unsafe fn clear_from_env_overrides() {
1717 unsafe {
1720 std::env::remove_var("OPENMW_CONFIG");
1721 std::env::remove_var("OPENMW_CONFIG_DIR");
1722 std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
1723 std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
1724 std::env::remove_var("OPENMW_FLATPAK_ID");
1725 std::env::remove_var("FLATPAK_ID");
1726 }
1727 }
1728
1729 fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1730 unsafe {
1732 if let Some(value) = value {
1733 std::env::set_var(key, value);
1734 } else {
1735 std::env::remove_var(key);
1736 }
1737 }
1738 }
1739
1740 fn load(cfg_contents: &str) -> OpenMWConfiguration {
1741 let dir = temp_dir();
1742 write_cfg(&dir, cfg_contents);
1743 OpenMWConfiguration::new(Some(dir)).unwrap()
1744 }
1745
1746 #[cfg(unix)]
1747 fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
1748 std::os::unix::fs::symlink(target, link).unwrap();
1749 }
1750
1751 #[test]
1756 fn test_content_files_empty_on_bare_config() {
1757 let config = load("");
1758 assert!(config.content_files_iter().next().is_none());
1759 }
1760
1761 #[test]
1762 fn test_content_files_parsed_in_order() {
1763 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
1764 let files: Vec<&String> = config
1765 .content_files_iter()
1766 .map(FileSetting::value)
1767 .collect();
1768 assert_eq!(
1769 files,
1770 vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
1771 );
1772 }
1773
1774 #[test]
1775 fn test_has_content_file_found() {
1776 let config = load("content=Morrowind.esm\n");
1777 assert!(config.has_content_file("Morrowind.esm"));
1778 }
1779
1780 #[test]
1781 fn test_has_content_file_not_found() {
1782 let config = load("content=Morrowind.esm\n");
1783 assert!(!config.has_content_file("Tribunal.esm"));
1784 }
1785
1786 #[test]
1787 fn test_duplicate_content_file_errors_on_load() {
1788 let dir = temp_dir();
1789 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1790 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1791 }
1792
1793 #[test]
1794 fn test_duplicate_content_file_error_reports_line_number() {
1795 let dir = temp_dir();
1796 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1797
1798 let result = OpenMWConfiguration::new(Some(dir));
1799 assert!(matches!(
1800 result,
1801 Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
1802 ));
1803 }
1804
1805 #[test]
1806 fn test_add_content_file_appends() {
1807 let mut config = load("content=Morrowind.esm\n");
1808 config.add_content_file("MyMod.esp").unwrap();
1809 assert!(config.has_content_file("MyMod.esp"));
1810 }
1811
1812 #[test]
1813 fn test_add_duplicate_content_file_errors() {
1814 let mut config = load("content=Morrowind.esm\n");
1815 assert!(config.add_content_file("Morrowind.esm").is_err());
1816 }
1817
1818 #[test]
1819 fn test_add_content_file_source_config_is_cfg_file() {
1820 let dir = temp_dir();
1821 let cfg_path = write_cfg(&dir, "");
1822 let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
1823 config.add_content_file("Mod.esp").unwrap();
1824 let setting = config.content_files_iter().next().unwrap();
1825 assert_eq!(
1826 setting.meta().source_config,
1827 cfg_path,
1828 "source_config should be the openmw.cfg file, not a directory"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_remove_content_file() {
1834 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1835 config.remove_content_file("Morrowind.esm");
1836 assert!(!config.has_content_file("Morrowind.esm"));
1837 assert!(config.has_content_file("Tribunal.esm"));
1838 }
1839
1840 #[test]
1841 fn test_set_content_files_replaces_all() {
1842 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1843 config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
1844 assert!(!config.has_content_file("Morrowind.esm"));
1845 assert!(!config.has_content_file("Tribunal.esm"));
1846 assert!(config.has_content_file("NewMod.esp"));
1847 }
1848
1849 #[test]
1850 fn test_set_content_files_none_clears_all() {
1851 let mut config = load("content=Morrowind.esm\n");
1852 config.set_content_files(None);
1853 assert!(config.content_files_iter().next().is_none());
1854 }
1855
1856 #[test]
1861 fn test_fallback_archives_parsed() {
1862 let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1863 let archives: Vec<&String> = config
1864 .fallback_archives_iter()
1865 .map(FileSetting::value)
1866 .collect();
1867 assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
1868 }
1869
1870 #[test]
1871 fn test_has_archive_file() {
1872 let config = load("fallback-archive=Morrowind.bsa\n");
1873 assert!(config.has_archive_file("Morrowind.bsa"));
1874 assert!(!config.has_archive_file("Tribunal.bsa"));
1875 }
1876
1877 #[test]
1878 fn test_add_duplicate_archive_errors() {
1879 let mut config = load("fallback-archive=Morrowind.bsa\n");
1880 assert!(config.add_archive_file("Morrowind.bsa").is_err());
1881 }
1882
1883 #[test]
1884 fn test_duplicate_archive_error_reports_line_number() {
1885 let dir = temp_dir();
1886 write_cfg(
1887 &dir,
1888 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
1889 );
1890
1891 let result = OpenMWConfiguration::new(Some(dir));
1892 assert!(matches!(
1893 result,
1894 Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
1895 ));
1896 }
1897
1898 #[test]
1899 fn test_remove_archive_file() {
1900 let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1901 config.remove_archive_file("Morrowind.bsa");
1902 assert!(!config.has_archive_file("Morrowind.bsa"));
1903 assert!(config.has_archive_file("Tribunal.bsa"));
1904 }
1905
1906 #[test]
1911 fn test_groundcover_parsed() {
1912 let config = load("groundcover=GrassPlugin.esp\n");
1913 let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
1914 assert_eq!(grass, vec!["GrassPlugin.esp"]);
1915 }
1916
1917 #[test]
1918 fn test_has_groundcover_file() {
1919 let config = load("groundcover=Grass.esp\n");
1920 assert!(config.has_groundcover_file("Grass.esp"));
1921 assert!(!config.has_groundcover_file("Other.esp"));
1922 }
1923
1924 #[test]
1925 fn test_duplicate_groundcover_errors_on_load() {
1926 let dir = temp_dir();
1927 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1928 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1929 }
1930
1931 #[test]
1932 fn test_duplicate_groundcover_error_reports_line_number() {
1933 let dir = temp_dir();
1934 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1935
1936 let result = OpenMWConfiguration::new(Some(dir));
1937 assert!(matches!(
1938 result,
1939 Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
1940 ));
1941 }
1942
1943 #[test]
1948 fn test_data_directories_absolute_paths_parsed() {
1949 let config = load("data=/absolute/path/to/data\n");
1950 assert!(
1951 config
1952 .data_directories_iter()
1953 .any(|d| d.parsed().ends_with("absolute/path/to/data"))
1954 );
1955 }
1956
1957 #[test]
1958 fn test_add_data_directory() {
1959 let mut config = load("");
1960 config.add_data_directory(Path::new("/some/data/dir"));
1961 assert!(config.has_data_dir("/some/data/dir"));
1962 }
1963
1964 #[test]
1965 fn test_set_data_directories_replaces_all() {
1966 let mut config = load("data=/old/dir\n");
1967 config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
1968 assert!(!config.has_data_dir("/old/dir"));
1969 assert!(config.has_data_dir("/new/dir"));
1970 }
1971
1972 #[test]
1973 fn test_remove_data_directory() {
1974 let mut config = load("data=/keep/me\n");
1975 config.add_data_directory(Path::new("/remove/me"));
1976 config.remove_data_directory(&PathBuf::from("/remove/me"));
1977 assert!(!config.has_data_dir("/remove/me"));
1978 assert!(config.has_data_dir("/keep/me"));
1979 }
1980
1981 #[test]
1986 fn test_game_settings_parsed() {
1987 let config = load("fallback=iMaxLevel,100\n");
1988 let setting = config.get_game_setting("iMaxLevel").unwrap();
1989 assert_eq!(setting.value(), "100");
1990 }
1991
1992 #[test]
1993 fn test_game_settings_last_wins() {
1994 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
1995 let setting = config.get_game_setting("iKey").unwrap();
1996 assert_eq!(setting.value(), "2");
1997 }
1998
1999 #[test]
2000 fn test_game_settings_deduplicates_by_key() {
2001 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2004 let results: Vec<_> = config
2005 .game_settings()
2006 .filter(|s| s.key() == "iKey")
2007 .collect();
2008 assert_eq!(
2009 results.len(),
2010 1,
2011 "game_settings() should deduplicate by key"
2012 );
2013 assert_eq!(results[0].value(), "2", "last-defined value should win");
2014 }
2015
2016 #[test]
2017 fn test_get_game_setting_missing_returns_none() {
2018 let config = load("fallback=iKey,1\n");
2019 assert!(config.get_game_setting("iMissing").is_none());
2020 }
2021
2022 #[test]
2023 fn test_game_setting_color_roundtrip() {
2024 let config = load("fallback=iSkyColor,100,149,237\n");
2025 let setting = config.get_game_setting("iSkyColor").unwrap();
2026 assert_eq!(setting.value(), "100,149,237");
2027 }
2028
2029 #[test]
2030 fn test_game_setting_float_roundtrip() {
2031 let config = load("fallback=fGravity,9.81\n");
2032 let setting = config.get_game_setting("fGravity").unwrap();
2033 assert_eq!(setting.value(), "9.81");
2034 }
2035
2036 #[test]
2037 fn test_invalid_game_setting_error_reports_line_number() {
2038 let dir = temp_dir();
2039 write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
2040
2041 let result = OpenMWConfiguration::new(Some(dir));
2042 assert!(matches!(
2043 result,
2044 Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
2045 ));
2046 }
2047
2048 #[test]
2053 fn test_encoding_parsed() {
2054 use crate::config::encodingsetting::EncodingType;
2055 let config = load("encoding=win1252\n");
2056 assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
2057 }
2058
2059 #[test]
2060 fn test_invalid_encoding_errors_on_load() {
2061 let dir = temp_dir();
2062 write_cfg(&dir, "encoding=utf8\n");
2063 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2064 }
2065
2066 #[test]
2067 fn test_invalid_encoding_error_reports_line_number() {
2068 let dir = temp_dir();
2069 write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
2070
2071 let result = OpenMWConfiguration::new(Some(dir));
2072 assert!(matches!(
2073 result,
2074 Err(ConfigError::BadEncoding { line: Some(2), .. })
2075 ));
2076 }
2077
2078 #[test]
2083 fn test_replace_content_clears_prior_plugins() {
2084 let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
2085 assert!(!config.has_content_file("Old.esm"));
2086 assert!(config.has_content_file("New.esm"));
2087 }
2088
2089 #[test]
2090 fn test_replace_data_clears_prior_dirs() {
2091 let config = load("data=/old\nreplace=data\ndata=/new\n");
2092 assert!(!config.has_data_dir("/old"));
2093 assert!(config.has_data_dir("/new"));
2094 }
2095
2096 #[test]
2097 fn test_replace_keeps_comment_adjacency() {
2098 let config = load("content=Old.esm\nreplace=content\n\n# keep me\ncontent=New.esm\n");
2099 let output = config.to_string();
2100
2101 assert!(!output.contains("Old.esm"));
2102 assert!(output.contains("# keep me\ncontent=New.esm"));
2103 }
2104
2105 #[test]
2110 fn test_display_contains_version_comment() {
2111 let config = load("content=Morrowind.esm\n");
2112 let output = config.to_string();
2113 assert!(
2114 output.contains("# OpenMW-Config Serializer Version:"),
2115 "Display should include version comment"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_display_preserves_content_entries() {
2121 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2122 let output = config.to_string();
2123 assert!(output.contains("content=Morrowind.esm"));
2124 assert!(output.contains("content=Tribunal.esm"));
2125 }
2126
2127 #[test]
2128 fn test_display_preserves_comments() {
2129 let config = load("# This is a comment\ncontent=Morrowind.esm\n");
2130 let output = config.to_string();
2131 assert!(output.contains("# This is a comment"));
2132 }
2133
2134 #[test]
2139 fn test_generic_setting_preserved() {
2140 let config = load("some-unknown-key=some-value\n");
2141 let output = config.to_string();
2142 assert!(output.contains("some-unknown-key=some-value"));
2143 }
2144
2145 #[test]
2146 fn test_generic_settings_can_be_replaced_and_iterated() {
2147 let mut config = load("no-sound=1\nno-sound=0\nother=keep\n");
2148
2149 config.set_generic_settings("no-sound", Some(vec!["2".to_string()]));
2150
2151 let values: Vec<(&str, &str)> = config
2152 .generic_settings_iter()
2153 .map(|setting| (setting.key(), setting.value()))
2154 .collect();
2155
2156 assert_eq!(values, vec![("other", "keep"), ("no-sound", "2")]);
2157 }
2158
2159 #[test]
2160 fn test_save_to_path_writes_exact_output_path() {
2161 let dir = temp_dir();
2162 write_cfg(&dir, "no-sound=1\ncontent=Morrowind.esm\n");
2163 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2164
2165 let out = temp_dir().join("imported-openmw.cfg");
2166 config.save_to_path(&out).unwrap();
2167
2168 let saved = std::fs::read_to_string(&out).unwrap();
2169 assert!(saved.contains("no-sound=1"));
2170 assert!(saved.contains("content=Morrowind.esm"));
2171 }
2172
2173 #[test]
2174 fn test_save_to_path_overwrites_read_only_existing_file() {
2175 #[cfg(unix)]
2176 {
2177 use std::os::unix::fs::PermissionsExt;
2178
2179 let dir = temp_dir();
2180 write_cfg(&dir, "no-sound=1\n");
2181 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2182
2183 let out = dir.join("export.cfg");
2184 std::fs::write(&out, "old=content\n").unwrap();
2185 std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o444)).unwrap();
2186
2187 config.save_to_path(&out).unwrap();
2188
2189 std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o644)).unwrap();
2190 let saved = std::fs::read_to_string(&out).unwrap();
2191 assert!(saved.contains("no-sound=1"));
2192 assert!(!saved.contains("old=content"));
2193 }
2194 }
2195
2196 #[test]
2197 fn test_save_to_path_rejects_unwritable_parent_directory() {
2198 #[cfg(unix)]
2199 {
2200 use std::os::unix::fs::PermissionsExt;
2201
2202 let dir = temp_dir();
2203 write_cfg(&dir, "no-sound=1\n");
2204 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2205
2206 let parent = temp_dir();
2207 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
2208
2209 let out = parent.join("export.cfg");
2210 let result = config.save_to_path(&out);
2211
2212 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
2213
2214 assert!(matches!(result, Err(ConfigError::NotWritable(_))));
2215 }
2216 }
2217
2218 #[test]
2223 fn test_save_user_round_trips_content_files() {
2224 let dir = temp_dir();
2225 write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
2226 let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2227 config.add_content_file("Bloodmoon.esm").unwrap();
2228 config.save_user().unwrap();
2229
2230 let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
2231 let files: Vec<&String> = reloaded
2232 .content_files_iter()
2233 .map(FileSetting::value)
2234 .collect();
2235 assert!(files.contains(&&"Morrowind.esm".to_string()));
2236 assert!(files.contains(&&"Bloodmoon.esm".to_string()));
2237 }
2238
2239 #[test]
2240 fn test_save_user_does_not_persist_injected_data_local_as_data() {
2241 let dir = temp_dir();
2242 let data_local = dir.join("data-local");
2243 write_cfg(
2244 &dir,
2245 &format!(
2246 "data-local={}\ndata={}\n",
2247 data_local.display(),
2248 data_local.display()
2249 ),
2250 );
2251
2252 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2253 assert!(config.has_data_dir(&data_local.to_string_lossy()));
2254
2255 config.save_user().unwrap();
2256
2257 let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2258 assert_eq!(saved.matches("data-local=").count(), 1);
2259 assert_eq!(saved.matches("data=").count(), 1);
2260 }
2261
2262 #[test]
2263 fn test_composite_display_does_not_serialize_injected_data_local_as_data() {
2264 let dir = temp_dir();
2265 let data_local = dir.join("data-local");
2266 write_cfg(&dir, &format!("data-local={}\n", data_local.display()));
2267
2268 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2269 let serialized = config.to_string();
2270
2271 assert!(serialized.contains("data-local="));
2272 assert!(!serialized.contains("data="));
2273 }
2274
2275 #[test]
2276 fn test_display_preserves_directory_originals_for_round_trip_boundary() {
2277 let dir = temp_dir();
2278 write_cfg(
2279 &dir,
2280 "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\n",
2281 );
2282
2283 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2284 let serialized = config.to_string();
2285
2286 assert!(serialized.contains("data=Data Files"));
2287 assert!(serialized.contains("data-local=\"Local Data\""));
2288 assert!(serialized.contains("resources=?local?/resources"));
2289 assert!(serialized.contains("user-data=?userdata?"));
2290 }
2291
2292 #[test]
2293 fn test_to_resolved_string_emits_resolved_directory_paths() {
2294 let dir = temp_dir();
2295 write_cfg(
2296 &dir,
2297 "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\ncontent=Base.esm\nfallback-archive=Morrowind.bsa\n",
2298 );
2299
2300 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2301 let serialized = config.to_resolved_string();
2302
2303 assert!(!serialized.contains("data=Data Files"));
2304 assert!(!serialized.contains("data-local=\"Local Data\""));
2305 assert!(!serialized.contains("resources=?local?/resources"));
2306 assert!(!serialized.contains("userdata="));
2307 assert!(serialized.contains(&format!("data={}", dir.join("Data Files").display())));
2308 assert!(serialized.contains(&format!("data-local={}", dir.join("Local Data").display())));
2309 assert!(serialized.contains("user-data="));
2310 assert!(serialized.contains("content=Base.esm"));
2311 assert!(serialized.contains("fallback-archive=Morrowind.bsa"));
2312 }
2313
2314 #[test]
2315 fn test_to_resolved_string_excludes_config_replace_and_synthetic_data_local() {
2316 let root = temp_dir();
2317 let sub = temp_dir();
2318 let data_local = root.join("local-data");
2319 write_cfg(
2320 &root,
2321 &format!(
2322 "data-local={}\nconfig={}\ncontent=Root.esm\n",
2323 data_local.display(),
2324 sub.display()
2325 ),
2326 );
2327 write_cfg(&sub, "content=Sub.esm\n");
2328
2329 let mut config = OpenMWConfiguration::new(Some(root)).unwrap();
2330 config.add_generic_setting("replace", "data");
2331
2332 let serialized = config.to_resolved_string();
2333
2334 assert!(!serialized.contains("config="));
2335 assert!(!serialized.contains("replace="));
2336 assert_eq!(serialized.matches("data-local=").count(), 1);
2337 assert_eq!(serialized.matches("data=").count(), 0);
2338 assert!(serialized.contains("content=Root.esm"));
2339 assert!(serialized.contains("content=Sub.esm"));
2340 }
2341
2342 #[test]
2343 fn test_save_resolved_to_path_writes_relocated_safe_output() {
2344 let dir = temp_dir();
2345 let out = temp_dir().join("openmw.cfg");
2346 write_cfg(&dir, "data=Data Files\ncontent=Base.esm\n");
2347
2348 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2349 config.save_resolved_to_path(&out).unwrap();
2350
2351 let saved = std::fs::read_to_string(out).unwrap();
2352 assert!(saved.contains(&format!("data={}", dir.join("Data Files").display())));
2353 assert!(!saved.contains("data=Data Files"));
2354 assert!(saved.contains("content=Base.esm"));
2355 }
2356
2357 #[test]
2358 fn test_save_user_not_writable_returns_error() {
2359 #[cfg(unix)]
2361 {
2362 use std::os::unix::fs::PermissionsExt;
2363 let dir = temp_dir();
2364 write_cfg(&dir, "content=Morrowind.esm\n");
2365 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2366
2367 let cfg_path = dir.join("openmw.cfg");
2369 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
2370
2371 let result = config.save_user();
2372 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
2374
2375 assert!(
2376 matches!(result, Err(ConfigError::NotWritable(_))),
2377 "expected NotWritable, got {result:?}"
2378 );
2379 }
2380 }
2381
2382 #[test]
2387 fn test_save_subconfig_rejects_unloaded_path() {
2388 let dir = temp_dir();
2389 write_cfg(&dir, "content=Morrowind.esm\n");
2390 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2391
2392 let fake_dir = temp_dir();
2393 let result = config.save_subconfig(&fake_dir);
2394 assert!(
2395 matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
2396 "expected SubconfigNotLoaded, got {result:?}"
2397 );
2398 }
2399
2400 #[test]
2401 fn test_save_subconfig_round_trips_settings() {
2402 let root_dir = temp_dir();
2403 let sub_dir = temp_dir();
2404 write_cfg(&sub_dir, "content=Plugin.esp\n");
2405 write_cfg(
2406 &root_dir,
2407 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
2408 );
2409
2410 let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
2411 config.add_content_file("NewPlugin.esp").unwrap();
2412 config.save_subconfig(&sub_dir).unwrap();
2413
2414 let sub_cfg = sub_dir.join("openmw.cfg");
2415 let saved = std::fs::read_to_string(sub_cfg).unwrap();
2416 assert!(
2417 saved.contains("content=Plugin.esp"),
2418 "sub-config content preserved"
2419 );
2420 }
2421
2422 #[test]
2423 #[cfg(unix)]
2424 fn test_save_subconfig_accepts_equivalent_symlink_path() {
2425 let root_dir = temp_dir();
2426 let real_sub_dir = temp_dir();
2427 let linked_sub_dir = temp_dir().join("linked_subconfig");
2428
2429 std::os::unix::fs::symlink(&real_sub_dir, &linked_sub_dir).unwrap();
2430 write_cfg(&real_sub_dir, "content=Plugin.esp\n");
2431 write_cfg(
2432 &root_dir,
2433 &format!(
2434 "content=Morrowind.esm\nconfig={}\n",
2435 linked_sub_dir.display()
2436 ),
2437 );
2438
2439 let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
2440 config.save_subconfig(&real_sub_dir).unwrap();
2441
2442 let saved = std::fs::read_to_string(real_sub_dir.join("openmw.cfg")).unwrap();
2443 assert!(
2444 saved.contains("content=Plugin.esp"),
2445 "sub-config settings should match by filesystem identity, not symlink spelling"
2446 );
2447 }
2448
2449 #[test]
2454 fn test_new_empty_uses_directory_as_user_config_context() {
2455 let dir = temp_dir().join("not-created-yet");
2456
2457 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2458 config.add_content_file("EmptyStart.esp").unwrap();
2459 config.add_data_directory(Path::new("Data Files"));
2460 config
2461 .set_game_setting("fScale,1.0", None, &mut String::new())
2462 .unwrap();
2463
2464 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2465 assert_eq!(config.root_config_dir(), dir);
2466 assert_eq!(config.user_config_path(), config.root_config_dir());
2467
2468 let content = config.content_files_iter().next().unwrap();
2469 assert_eq!(content.meta().source_config, config.root_config_file());
2470
2471 let data = config.data_directories_iter().next().unwrap();
2472 assert_eq!(data.meta().source_config, config.root_config_file());
2473 assert_eq!(data.parsed(), &config.root_config_dir().join("Data Files"));
2474
2475 let game_setting = config.get_game_setting("fScale").unwrap();
2476 assert_eq!(game_setting.meta().source_config, config.root_config_file());
2477 }
2478
2479 #[test]
2480 fn test_new_empty_rejects_openmw_cfg_file_shaped_path() {
2481 let result = OpenMWConfiguration::new_empty(temp_dir().join("openmw.cfg"));
2482 assert!(matches!(result, Err(ConfigError::NotFileOrDirectory(_))));
2483 }
2484
2485 #[test]
2486 fn test_save_to_path_works_from_new_empty_config() {
2487 let dir = temp_dir().join("missing-config-dir");
2488 let out_dir = temp_dir();
2489 let out = out_dir.join("openmw.cfg");
2490
2491 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2492 config.add_content_file("Generated.esp").unwrap();
2493 config.save_to_path(&out).unwrap();
2494
2495 let saved = std::fs::read_to_string(out).unwrap();
2496 assert!(saved.contains("content=Generated.esp"));
2497 }
2498
2499 #[test]
2500 fn test_save_user_creates_missing_new_empty_config_directory() {
2501 let dir = temp_dir().join("missing-user-config-dir");
2502
2503 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2504 config.add_content_file("Generated.esp").unwrap();
2505 config.save_user().unwrap();
2506
2507 let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2508 assert!(saved.contains("content=Generated.esp"));
2509 }
2510
2511 #[test]
2512 fn test_load_optional_existing_cfg_matches_new() {
2513 let dir = temp_dir();
2514 let cfg = write_cfg(&dir, "content=Existing.esm\ndata=Data Files\n");
2515
2516 let from_new = OpenMWConfiguration::new(Some(cfg.clone())).unwrap();
2517 let from_optional = OpenMWConfiguration::load_optional(&cfg).unwrap();
2518
2519 assert_eq!(
2520 from_optional.root_config_file(),
2521 from_new.root_config_file()
2522 );
2523 assert_eq!(from_optional.to_string(), from_new.to_string());
2524 }
2525
2526 #[test]
2527 fn test_load_optional_missing_cfg_uses_parent_context() {
2528 let dir = temp_dir().join("cfg-parent");
2529 let cfg = dir.join("openmw.cfg");
2530
2531 let mut config = OpenMWConfiguration::load_optional(&cfg).unwrap();
2532 config.add_data_directory(Path::new("Data Files"));
2533
2534 assert_eq!(config.root_config_file(), cfg);
2535 assert_eq!(config.root_config_dir(), dir);
2536 assert_eq!(
2537 config.data_directories_iter().next().unwrap().parsed(),
2538 &config.root_config_dir().join("Data Files")
2539 );
2540 }
2541
2542 #[test]
2543 fn test_load_optional_existing_empty_dir_uses_dir_context() {
2544 let dir = temp_dir();
2545
2546 let mut config = OpenMWConfiguration::load_optional(&dir).unwrap();
2547 config.add_data_directory(Path::new("Data Files"));
2548
2549 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2550 assert_eq!(config.root_config_dir(), dir);
2551 assert_eq!(
2552 config.data_directories_iter().next().unwrap().parsed(),
2553 &config.root_config_dir().join("Data Files")
2554 );
2555 }
2556
2557 #[test]
2558 fn test_load_optional_missing_dir_uses_dir_context() {
2559 let dir = temp_dir().join("missing-dir");
2560
2561 let config = OpenMWConfiguration::load_optional(&dir).unwrap();
2562
2563 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2564 assert_eq!(config.root_config_dir(), dir);
2565 }
2566
2567 #[test]
2568 fn test_new_empty_relative_dir_anchors_to_current_dir_for_resolved_export() {
2569 let relative_dir = PathBuf::from(format!(
2570 "openmw-config-relative-empty-{}",
2571 std::process::id()
2572 ));
2573 let expected_dir = util::display_preserving_absolute(&relative_dir).unwrap();
2574
2575 let mut config = OpenMWConfiguration::new_empty(&relative_dir).unwrap();
2576 config.add_data_directory(Path::new("Data Files"));
2577
2578 assert_eq!(config.root_config_dir(), expected_dir);
2579 assert!(config.to_resolved_string().contains(&format!(
2580 "data={}",
2581 expected_dir.join("Data Files").display()
2582 )));
2583 }
2584
2585 #[test]
2586 fn test_load_optional_relative_missing_cfg_anchors_to_current_dir() {
2587 let relative_cfg = PathBuf::from(format!(
2588 "openmw-config-relative-missing-{}/openmw.cfg",
2589 std::process::id()
2590 ));
2591 let expected_dir =
2592 util::display_preserving_absolute(relative_cfg.parent().unwrap()).unwrap();
2593
2594 let mut config = OpenMWConfiguration::load_optional(&relative_cfg).unwrap();
2595 config.add_data_directory(Path::new("Data Files"));
2596
2597 assert_eq!(config.root_config_dir(), expected_dir);
2598 assert!(config.to_resolved_string().contains(&format!(
2599 "data={}",
2600 expected_dir.join("Data Files").display()
2601 )));
2602 }
2603
2604 #[test]
2605 fn test_path_level_directory_singleton_setters_use_user_config_context() {
2606 let dir = temp_dir();
2607 let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2608
2609 config.set_data_local_path("local-data");
2610 config.set_resources_path("resources-dir");
2611 config.set_user_data_path("user-data-dir");
2612
2613 assert_eq!(
2614 config.data_local().unwrap().meta().source_config,
2615 config.root_config_file()
2616 );
2617 assert_eq!(
2618 config.resources().unwrap().meta().source_config,
2619 config.root_config_file()
2620 );
2621 assert_eq!(
2622 config.userdata().unwrap().meta().source_config,
2623 config.root_config_file()
2624 );
2625 assert_eq!(
2626 config.data_local().unwrap().parsed(),
2627 &dir.join("local-data")
2628 );
2629 assert_eq!(
2630 config.resources().unwrap().parsed(),
2631 &dir.join("resources-dir")
2632 );
2633 assert_eq!(
2634 config.userdata().unwrap().parsed(),
2635 &dir.join("user-data-dir")
2636 );
2637
2638 let serialized = config.to_string();
2639 assert!(serialized.contains("data-local=local-data"));
2640 assert!(serialized.contains("resources=resources-dir"));
2641 assert!(serialized.contains("user-data=user-data-dir"));
2642 assert!(!serialized.contains("userdata="));
2643 }
2644
2645 #[test]
2646 fn test_repeated_path_level_singleton_setters_replace() {
2647 let dir = temp_dir();
2648 let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
2649
2650 config.set_data_local_path("first-local");
2651 config.set_data_local_path("second-local");
2652 config.set_resources_path("first-resources");
2653 config.set_resources_path("second-resources");
2654 config.set_user_data_path("first-user-data");
2655 config.set_user_data_path("second-user-data");
2656
2657 let serialized = config.to_string();
2658 assert_eq!(serialized.matches("data-local=").count(), 1);
2659 assert_eq!(serialized.matches("resources=").count(), 1);
2660 assert_eq!(serialized.matches("user-data=").count(), 1);
2661 assert!(serialized.contains("data-local=second-local"));
2662 assert!(serialized.contains("resources=second-resources"));
2663 assert!(serialized.contains("user-data=second-user-data"));
2664 }
2665
2666 #[test]
2667 fn test_path_level_directory_singleton_clearers_remove_values() {
2668 let dir = temp_dir();
2669 let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
2670
2671 config.set_data_local_path("local-data");
2672 config.set_resources_path("resources-dir");
2673 config.set_user_data_path("user-data-dir");
2674
2675 config.clear_data_local();
2676 config.clear_resources();
2677 config.clear_user_data();
2678
2679 assert!(config.data_local().is_none());
2680 assert!(config.resources().is_none());
2681 assert!(config.userdata().is_none());
2682 }
2683
2684 #[test]
2689 fn test_from_env_openmw_config_dir() {
2690 let _guard = env_lock();
2691 let dir = temp_dir();
2692 write_cfg(&dir, "content=Morrowind.esm\n");
2693
2694 unsafe {
2697 clear_from_env_overrides();
2698 std::env::set_var("OPENMW_CONFIG_DIR", &dir);
2699 }
2700 let config = OpenMWConfiguration::from_env().unwrap();
2701 unsafe { clear_from_env_overrides() };
2702
2703 assert!(config.has_content_file("Morrowind.esm"));
2704 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2705 }
2706
2707 #[test]
2708 fn test_from_env_openmw_config_file() {
2709 let _guard = env_lock();
2710 let dir = temp_dir();
2711 let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
2712
2713 unsafe {
2714 clear_from_env_overrides();
2715 std::env::set_var("OPENMW_CONFIG", &cfg);
2716 }
2717 let config = OpenMWConfiguration::from_env().unwrap();
2718 unsafe { clear_from_env_overrides() };
2719
2720 assert!(config.has_content_file("Tribunal.esm"));
2721 assert_eq!(config.root_config_file(), cfg);
2722 }
2723
2724 #[test]
2725 fn test_from_env_prefers_openmw_config_file_over_dir() {
2726 let _guard = env_lock();
2727 let file_dir = temp_dir();
2728 let dir_dir = temp_dir();
2729 let cfg = write_cfg(&file_dir, "content=FileWins.esm\n");
2730 write_cfg(&dir_dir, "content=DirLoses.esm\n");
2731
2732 unsafe {
2733 clear_from_env_overrides();
2734 std::env::set_var("OPENMW_CONFIG", &cfg);
2735 std::env::set_var("OPENMW_CONFIG_DIR", &dir_dir);
2736 }
2737
2738 let config = OpenMWConfiguration::from_env().unwrap();
2739 unsafe { clear_from_env_overrides() };
2740
2741 assert_eq!(config.root_config_file(), cfg);
2742 assert!(config.has_content_file("FileWins.esm"));
2743 assert!(!config.has_content_file("DirLoses.esm"));
2744 }
2745
2746 #[test]
2747 fn test_from_env_prefers_first_openmw_config_dir_entry() {
2748 let _guard = env_lock();
2749 let missing_dir = temp_dir().join("missing");
2750 let first_valid = temp_dir();
2751 let second_valid = temp_dir();
2752 write_cfg(&first_valid, "content=FirstValid.esm\n");
2753 write_cfg(&second_valid, "content=SecondValid.esm\n");
2754
2755 let separator = if cfg!(windows) { ";" } else { ":" };
2756 let path_list = format!(
2757 "{}{}{}{}{}",
2758 missing_dir.display(),
2759 separator,
2760 first_valid.display(),
2761 separator,
2762 second_valid.display()
2763 );
2764
2765 unsafe {
2766 clear_from_env_overrides();
2767 std::env::set_var("OPENMW_CONFIG_DIR", path_list);
2768 }
2769
2770 let config = OpenMWConfiguration::from_env().unwrap();
2771 unsafe { clear_from_env_overrides() };
2772
2773 assert_eq!(config.root_config_file(), first_valid.join("openmw.cfg"));
2774 assert!(config.has_content_file("FirstValid.esm"));
2775 assert!(!config.has_content_file("SecondValid.esm"));
2776 }
2777
2778 #[test]
2779 fn test_from_env_config_dir_skips_non_file_openmw_cfg_entry() {
2780 let _guard = env_lock();
2781 let invalid_dir = temp_dir();
2782 let valid_dir = temp_dir();
2783 std::fs::create_dir_all(invalid_dir.join("openmw.cfg")).unwrap();
2784 write_cfg(&valid_dir, "content=ActualFile.esm\n");
2785
2786 let separator = if cfg!(windows) { ";" } else { ":" };
2787 let path_list = format!(
2788 "{}{}{}",
2789 invalid_dir.display(),
2790 separator,
2791 valid_dir.display()
2792 );
2793
2794 unsafe {
2795 clear_from_env_overrides();
2796 std::env::set_var("OPENMW_CONFIG_DIR", path_list);
2797 }
2798
2799 let config = OpenMWConfiguration::from_env().unwrap();
2800 unsafe { clear_from_env_overrides() };
2801
2802 assert_eq!(config.root_config_file(), valid_dir.join("openmw.cfg"));
2803 assert!(config.has_content_file("ActualFile.esm"));
2804 }
2805
2806 #[test]
2807 fn test_from_env_uses_global_config_when_no_explicit_env() {
2808 let _guard = env_lock();
2809 let global_dir = temp_dir();
2810 let cfg = write_cfg(&global_dir, "content=GlobalRoot.esm\n");
2811
2812 unsafe {
2813 clear_from_env_overrides();
2814 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
2815 }
2816
2817 let config = OpenMWConfiguration::from_env().unwrap();
2818 unsafe { clear_from_env_overrides() };
2819
2820 assert_eq!(config.root_config_file(), cfg);
2821 assert!(config.has_content_file("GlobalRoot.esm"));
2822 }
2823
2824 #[test]
2825 fn test_from_env_errors_without_local_or_global_root_config() {
2826 let _guard = env_lock();
2827 let empty_global_dir = temp_dir();
2828
2829 unsafe {
2830 clear_from_env_overrides();
2831 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2832 }
2833
2834 let result = OpenMWConfiguration::from_env();
2835 unsafe { clear_from_env_overrides() };
2836
2837 assert!(
2838 matches!(result, Err(ConfigError::CannotFindRootConfig { .. })),
2839 "expected CannotFindRootConfig, got {result:?}"
2840 );
2841 }
2842
2843 #[test]
2844 #[cfg(not(windows))]
2845 fn test_from_env_or_user_config_falls_back_to_user_config() {
2846 let _guard = env_lock();
2847 let empty_global_dir = temp_dir();
2848 let test_home = temp_dir();
2849 let test_xdg_config_home = temp_dir();
2850 let old_home = std::env::var_os("HOME");
2851 let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
2852
2853 unsafe {
2854 clear_from_env_overrides();
2855 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2856 std::env::set_var("HOME", &test_home);
2857 std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
2858 }
2859
2860 let user_config_dir = crate::try_default_config_path().unwrap();
2861 std::fs::create_dir_all(&user_config_dir).unwrap();
2862 let user_cfg = write_cfg(&user_config_dir, "content=UserOnly.esm\n");
2863
2864 let result = OpenMWConfiguration::from_env_or_user_config();
2865 unsafe {
2866 clear_from_env_overrides();
2867 }
2868 restore_env_var("HOME", old_home);
2869 restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
2870
2871 let config = result.unwrap();
2872 assert_eq!(config.root_config_file(), user_cfg);
2873 assert!(config.has_content_file("UserOnly.esm"));
2874 }
2875
2876 #[test]
2877 #[cfg(not(windows))]
2878 fn test_from_env_or_user_config_errors_when_no_candidates_exist() {
2879 let _guard = env_lock();
2880 let empty_global_dir = temp_dir();
2881 let test_home = temp_dir();
2882 let test_xdg_config_home = temp_dir();
2883 let old_home = std::env::var_os("HOME");
2884 let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
2885
2886 unsafe {
2887 clear_from_env_overrides();
2888 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2889 std::env::set_var("HOME", &test_home);
2890 std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
2891 }
2892
2893 let result = OpenMWConfiguration::from_env_or_user_config();
2894 unsafe {
2895 clear_from_env_overrides();
2896 }
2897 restore_env_var("HOME", old_home);
2898 restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
2899
2900 assert!(
2901 matches!(result, Err(ConfigError::CannotFindAnyConfig { .. })),
2902 "expected CannotFindAnyConfig, got {result:?}"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_global_root_config_loads_config_chain() {
2908 let _guard = env_lock();
2909 let global_dir = temp_dir();
2910 let user_dir = temp_dir();
2911 write_cfg(&user_dir, "content=UserConfig.esm\n");
2912 let root = write_cfg(
2913 &global_dir,
2914 &format!("content=GlobalRoot.esm\nconfig={}\n", user_dir.display()),
2915 );
2916
2917 unsafe {
2918 clear_from_env_overrides();
2919 std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
2920 }
2921
2922 let config = OpenMWConfiguration::from_env().unwrap();
2923 unsafe { clear_from_env_overrides() };
2924
2925 assert_eq!(config.root_config_file(), root);
2926 assert!(config.has_content_file("GlobalRoot.esm"));
2927 assert!(config.has_content_file("UserConfig.esm"));
2928 }
2929
2930 #[test]
2935 fn test_error_duplicate_archive_file() {
2936 let dir = temp_dir();
2938 write_cfg(
2939 &dir,
2940 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
2941 );
2942 let result = OpenMWConfiguration::new(Some(dir));
2943 assert!(matches!(
2944 result,
2945 Err(ConfigError::DuplicateArchiveFile { .. })
2946 ));
2947 }
2948
2949 #[test]
2950 fn test_error_cannot_add_groundcover_file() {
2951 let mut config = load("groundcover=GrassPlugin.esp\n");
2952 let result = config.add_groundcover_file("GrassPlugin.esp");
2953 assert!(matches!(
2954 result,
2955 Err(ConfigError::CannotAddGroundcoverFile { .. })
2956 ));
2957 }
2958
2959 #[test]
2960 fn test_error_cannot_find() {
2961 let result =
2962 OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
2963 assert!(matches!(
2964 result,
2965 Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
2966 ));
2967 }
2968
2969 #[test]
2970 fn test_error_io_from_conversion() {
2971 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
2972 let config_err: ConfigError = io_err.into();
2973 assert!(matches!(config_err, ConfigError::Io(_)));
2974 }
2975
2976 #[test]
2977 fn test_error_invalid_line() {
2978 let result = OpenMWConfiguration::new(Some({
2980 let dir = temp_dir();
2981 write_cfg(&dir, "this_has_no_equals_sign\n");
2982 dir
2983 }));
2984 assert!(matches!(
2985 result,
2986 Err(ConfigError::InvalidLine { line: Some(1), .. })
2987 ));
2988 }
2989
2990 #[test]
2991 fn test_error_max_depth_exceeded() {
2992 let dir = temp_dir();
2994 write_cfg(&dir, &format!("config={}\n", dir.display()));
2995 let result = OpenMWConfiguration::new(Some(dir));
2996 assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
2997 }
2998
2999 #[test]
3000 fn test_error_max_depth_exceeded_for_circular_chain() {
3001 let a = temp_dir();
3002 let b = temp_dir();
3003
3004 write_cfg(&a, &format!("config={}\n", b.display()));
3005 write_cfg(&b, &format!("config={}\n", a.display()));
3006
3007 let result = OpenMWConfiguration::new(Some(a));
3008 assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
3009 }
3010
3011 #[cfg(unix)]
3012 #[test]
3013 fn test_symlinked_config_dir_loads_like_real_path() {
3014 let real_dir = temp_dir();
3015 write_cfg(&real_dir, "content=Morrowind.esm\n");
3016
3017 let link_parent = temp_dir();
3018 let link_path = link_parent.join("symlinked-config");
3019 if link_path.exists() {
3020 let _ = std::fs::remove_file(&link_path);
3021 let _ = std::fs::remove_dir_all(&link_path);
3022 }
3023 symlink_dir(&real_dir, &link_path);
3024
3025 let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
3026
3027 assert!(config.has_content_file("Morrowind.esm"));
3028 assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
3029 assert_eq!(config.root_config_dir(), link_path);
3030 }
3031
3032 #[test]
3037 fn test_settings_matching_filters_correctly() {
3038 let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
3039 let content_count = config
3040 .settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
3041 .count();
3042 assert_eq!(content_count, 1);
3043 }
3044
3045 #[test]
3046 fn test_clear_matching_removes_entries() {
3047 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
3048 config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
3049 assert_eq!(config.content_files_iter().count(), 0);
3050 }
3051
3052 #[test]
3057 fn test_sub_configs_iteration() {
3058 let root_dir = temp_dir();
3059 let sub_dir = temp_dir();
3060 write_cfg(&sub_dir, "content=Plugin.esp\n");
3061 write_cfg(
3062 &root_dir,
3063 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3064 );
3065
3066 let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3067 assert_eq!(config.sub_configs().count(), 1);
3068 assert!(
3069 config.has_content_file("Plugin.esp"),
3070 "sub-config content visible in root"
3071 );
3072 }
3073
3074 #[test]
3075 fn test_config_chain_priority_order_for_data_lists_matches_openmw_docs_example() {
3076 let dir1 = temp_dir();
3077 let dir2 = temp_dir();
3078 let dir3 = temp_dir();
3079 let dir4 = temp_dir();
3080
3081 write_cfg(
3082 &dir1,
3083 &format!(
3084 "data=root-a\nconfig={}\nconfig={}\n",
3085 dir2.display(),
3086 dir3.display()
3087 ),
3088 );
3089 write_cfg(
3090 &dir2,
3091 &format!("data=branch-a\nconfig={}\n", dir4.display()),
3092 );
3093 write_cfg(&dir3, "data=sibling-a\n");
3094 write_cfg(&dir4, "data=leaf-a\n");
3095
3096 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3097 let actual: Vec<String> = config
3098 .data_directories_iter()
3099 .map(|setting| setting.original().clone())
3100 .collect();
3101
3102 assert_eq!(actual, vec!["root-a", "branch-a", "sibling-a", "leaf-a"]);
3103 }
3104
3105 #[test]
3106 fn test_replace_data_preserves_docs_priority_order_in_branching_chain() {
3107 let dir1 = temp_dir();
3108 let dir2 = temp_dir();
3109 let dir3 = temp_dir();
3110 let dir4 = temp_dir();
3111
3112 write_cfg(
3113 &dir1,
3114 &format!(
3115 "data=root-a\nconfig={}\nconfig={}\n",
3116 dir2.display(),
3117 dir3.display()
3118 ),
3119 );
3120 write_cfg(
3121 &dir2,
3122 &format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
3123 );
3124 write_cfg(&dir3, "data=sibling-a\n");
3125 write_cfg(&dir4, "data=leaf-a\n");
3126
3127 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3128 let actual: Vec<String> = config
3129 .data_directories_iter()
3130 .map(|setting| setting.original().clone())
3131 .collect();
3132
3133 assert_eq!(actual, vec!["branch-a", "sibling-a", "leaf-a"]);
3134 }
3135
3136 #[test]
3137 fn test_config_chain_priority_order_for_content_lists_matches_openmw_docs_example() {
3138 let dir1 = temp_dir();
3139 let dir2 = temp_dir();
3140 let dir3 = temp_dir();
3141 let dir4 = temp_dir();
3142
3143 write_cfg(
3144 &dir1,
3145 &format!(
3146 "content=Root.esm\nconfig={}\nconfig={}\n",
3147 dir2.display(),
3148 dir3.display()
3149 ),
3150 );
3151 write_cfg(
3152 &dir2,
3153 &format!("content=Branch.esm\nconfig={}\n", dir4.display()),
3154 );
3155 write_cfg(&dir3, "content=Sibling.esm\n");
3156 write_cfg(&dir4, "content=Leaf.esm\n");
3157
3158 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3159 let actual: Vec<String> = config
3160 .content_files_iter()
3161 .map(|setting| setting.value().clone())
3162 .collect();
3163
3164 assert_eq!(
3165 actual,
3166 vec!["Root.esm", "Branch.esm", "Sibling.esm", "Leaf.esm"],
3167 "content= should follow the same chain priority order as documented for config= traversal"
3168 );
3169 }
3170
3171 #[test]
3172 fn test_config_chain_priority_order_for_groundcover_lists_matches_openmw_docs_example() {
3173 let dir1 = temp_dir();
3174 let dir2 = temp_dir();
3175 let dir3 = temp_dir();
3176 let dir4 = temp_dir();
3177
3178 write_cfg(
3179 &dir1,
3180 &format!(
3181 "groundcover=Root.esp\nconfig={}\nconfig={}\n",
3182 dir2.display(),
3183 dir3.display()
3184 ),
3185 );
3186 write_cfg(
3187 &dir2,
3188 &format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
3189 );
3190 write_cfg(&dir3, "groundcover=Sibling.esp\n");
3191 write_cfg(&dir4, "groundcover=Leaf.esp\n");
3192
3193 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3194 let actual: Vec<String> = config
3195 .groundcover_iter()
3196 .map(|setting| setting.value().clone())
3197 .collect();
3198
3199 assert_eq!(
3200 actual,
3201 vec!["Root.esp", "Branch.esp", "Sibling.esp", "Leaf.esp"],
3202 "groundcover= should follow the same chain priority order as documented for config= traversal"
3203 );
3204 }
3205
3206 #[test]
3207 fn test_config_chain_priority_order_matches_openmw_docs_example() {
3208 let dir1 = temp_dir();
3209 let dir2 = temp_dir();
3210 let dir3 = temp_dir();
3211 let dir4 = temp_dir();
3212
3213 write_cfg(
3214 &dir1,
3215 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3216 );
3217 write_cfg(
3218 &dir2,
3219 &format!("encoding=win1250\nconfig={}\n", dir4.display()),
3220 );
3221 write_cfg(&dir3, "encoding=win1251\n");
3222 write_cfg(&dir4, "encoding=win1252\n");
3223
3224 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3225
3226 assert_eq!(
3227 config.encoding().unwrap().to_string().trim(),
3228 "encoding=win1252"
3229 );
3230 assert_eq!(config.user_config_path(), dir4);
3231 }
3232
3233 #[test]
3234 fn test_config_chain_priority_order_with_user_data_crosscheck() {
3235 let dir1 = temp_dir();
3236 let dir2 = temp_dir();
3237 let dir3 = temp_dir();
3238 let dir4 = temp_dir();
3239
3240 write_cfg(
3241 &dir1,
3242 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3243 );
3244 write_cfg(
3245 &dir2,
3246 &format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
3247 );
3248 write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
3249 write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
3250
3251 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3252
3253 assert_eq!(config.user_config_path(), dir4);
3254 assert_eq!(config.userdata().unwrap().parsed(), dir4.as_path());
3255 }
3256
3257 #[test]
3262 fn test_root_config_file_points_to_cfg() {
3263 let dir = temp_dir();
3264 write_cfg(&dir, "");
3265 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3266 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3267 }
3268
3269 #[test]
3270 fn test_root_config_dir_is_parent() {
3271 let dir = temp_dir();
3272 write_cfg(&dir, "");
3273 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3274 assert_eq!(config.root_config_dir(), dir);
3275 }
3276
3277 #[test]
3282 fn test_clone_is_independent() {
3283 let mut original = load("content=Morrowind.esm\n");
3284 let mut cloned = original.clone();
3285 cloned.add_content_file("Tribunal.esm").unwrap();
3286 original.add_content_file("Bloodmoon.esm").unwrap();
3287 assert!(cloned.has_content_file("Tribunal.esm"));
3288 assert!(!cloned.has_content_file("Bloodmoon.esm"));
3289 assert!(original.has_content_file("Bloodmoon.esm"));
3290 assert!(!original.has_content_file("Tribunal.esm"));
3291 }
3292
3293 fn assert_indexes_consistent(config: &OpenMWConfiguration) {
3294 use std::collections::{HashMap, HashSet};
3295
3296 config.ensure_game_setting_indexes();
3297
3298 let scanned_content: HashSet<String> = config
3299 .settings
3300 .iter()
3301 .filter_map(|setting| match setting {
3302 SettingValue::ContentFile(file) => Some(file.value().clone()),
3303 _ => None,
3304 })
3305 .collect();
3306 let scanned_groundcover: HashSet<String> = config
3307 .settings
3308 .iter()
3309 .filter_map(|setting| match setting {
3310 SettingValue::Groundcover(file) => Some(file.value().clone()),
3311 _ => None,
3312 })
3313 .collect();
3314 let scanned_archives: HashSet<String> = config
3315 .settings
3316 .iter()
3317 .filter_map(|setting| match setting {
3318 SettingValue::BethArchive(file) => Some(file.value().clone()),
3319 _ => None,
3320 })
3321 .collect();
3322 let scanned_data_dirs: HashSet<PathBuf> = config
3323 .settings
3324 .iter()
3325 .filter_map(|setting| match setting {
3326 SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
3327 _ => None,
3328 })
3329 .collect();
3330
3331 let mut scanned_game_setting_last = HashMap::new();
3332 for (index, setting) in config.settings.iter().enumerate() {
3333 if let SettingValue::GameSetting(game_setting) = setting {
3334 scanned_game_setting_last.insert(game_setting.key().clone(), index);
3335 }
3336 }
3337
3338 let mut scanned_game_setting_order = Vec::new();
3339 let mut seen = HashSet::new();
3340 for (index, setting) in config.settings.iter().enumerate().rev() {
3341 if let SettingValue::GameSetting(game_setting) = setting
3342 && seen.insert(game_setting.key())
3343 {
3344 scanned_game_setting_order.push(index);
3345 }
3346 }
3347
3348 assert_eq!(config.indexed_content, scanned_content);
3349 assert_eq!(config.indexed_groundcover, scanned_groundcover);
3350 assert_eq!(config.indexed_archives, scanned_archives);
3351 assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
3352 assert_eq!(
3353 *config.indexed_game_setting_last.borrow(),
3354 scanned_game_setting_last
3355 );
3356 assert_eq!(
3357 *config.indexed_game_setting_order.borrow(),
3358 scanned_game_setting_order
3359 );
3360
3361 for file in &config.indexed_content {
3362 assert!(config.has_content_file(file));
3363 }
3364 for file in &config.indexed_groundcover {
3365 assert!(config.has_groundcover_file(file));
3366 }
3367 for file in &config.indexed_archives {
3368 assert!(config.has_archive_file(file));
3369 }
3370 for dir in &config.indexed_data_dirs {
3371 assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
3372 }
3373
3374 let iter_keys: Vec<String> = config
3375 .game_settings()
3376 .map(|setting| setting.key().clone())
3377 .collect();
3378 let expected_keys: Vec<String> = config
3379 .indexed_game_setting_order
3380 .borrow()
3381 .iter()
3382 .filter_map(|index| match &config.settings[*index] {
3383 SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
3384 _ => None,
3385 })
3386 .collect();
3387 assert_eq!(iter_keys, expected_keys);
3388
3389 for (key, index) in config.indexed_game_setting_last.borrow().iter() {
3390 let expected_value = match &config.settings[*index] {
3391 SettingValue::GameSetting(game_setting) => game_setting.value(),
3392 _ => unreachable!("game setting index points to non-game setting"),
3393 };
3394 assert_eq!(
3395 config.get_game_setting(key).map(GameSettingType::value),
3396 Some(expected_value)
3397 );
3398 }
3399 }
3400
3401 #[test]
3402 fn test_indexes_remain_coherent_through_mutations() {
3403 let mut config = load(
3404 "content=Morrowind.esm\n\
3405content=Tribunal.esm\n\
3406groundcover=Grass.esp\n\
3407data=/tmp/data\n\
3408fallback-archive=Morrowind.bsa\n\
3409fallback=iGamma,1.00\n",
3410 );
3411 assert_indexes_consistent(&config);
3412
3413 config.add_content_file("Bloodmoon.esm").unwrap();
3414 assert_indexes_consistent(&config);
3415
3416 config.remove_content_file("Tribunal.esm");
3417 assert_indexes_consistent(&config);
3418
3419 config.add_groundcover_file("Flora.esp").unwrap();
3420 assert_indexes_consistent(&config);
3421
3422 config.remove_groundcover_file("Grass.esp");
3423 assert_indexes_consistent(&config);
3424
3425 config.add_archive_file("Tribunal.bsa").unwrap();
3426 assert_indexes_consistent(&config);
3427
3428 config.remove_archive_file("Morrowind.bsa");
3429 assert_indexes_consistent(&config);
3430
3431 config.add_data_directory(Path::new("/tmp/extra-data"));
3432 assert_indexes_consistent(&config);
3433
3434 config.remove_data_directory(&PathBuf::from("/tmp/data"));
3435 assert_indexes_consistent(&config);
3436
3437 config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
3438 assert_indexes_consistent(&config);
3439
3440 config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
3441 assert_indexes_consistent(&config);
3442
3443 config
3444 .set_game_settings(Some(vec![
3445 "iFoo,10".to_string(),
3446 "iFoo,11".to_string(),
3447 "fBar,1.5".to_string(),
3448 ]))
3449 .unwrap();
3450 assert_indexes_consistent(&config);
3451
3452 let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
3453 assert!(err.is_err());
3454 assert_indexes_consistent(&config);
3455
3456 config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
3457 assert_indexes_consistent(&config);
3458 }
3459
3460 #[test]
3461 fn test_indexes_coherent_after_replace_during_load() {
3462 let config = load(
3463 "content=Root.esm\n\
3464replace=content\n\
3465content=AfterReplace.esm\n\
3466groundcover=GrassRoot.esp\n\
3467replace=groundcover\n\
3468groundcover=GrassAfter.esp\n\
3469fallback-archive=Root.bsa\n\
3470replace=fallback-archives\n\
3471fallback-archive=After.bsa\n\
3472fallback=iFoo,1\n\
3473replace=fallback\n\
3474fallback=iFoo,2\n",
3475 );
3476
3477 assert_indexes_consistent(&config);
3478 assert!(config.has_content_file("AfterReplace.esm"));
3479 assert!(!config.has_content_file("Root.esm"));
3480 assert!(config.has_groundcover_file("GrassAfter.esp"));
3481 assert!(!config.has_groundcover_file("GrassRoot.esp"));
3482 assert!(config.has_archive_file("After.bsa"));
3483 assert!(!config.has_archive_file("Root.bsa"));
3484 assert_eq!(
3485 config.get_game_setting("iFoo").map(GameSettingType::value),
3486 Some("2".into())
3487 );
3488 }
3489}