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
149macro_rules! insert_dir_setting {
150 ($self:ident, $variant:ident, $value:expr, $config_file:expr, $comment:expr) => {{
151 $self
152 .settings
153 .push(SettingValue::$variant(DirectorySetting::new(
154 $value,
155 $config_file,
156 $comment,
157 )));
158 }};
159}
160
161#[derive(Debug, Default, Clone)]
168pub struct OpenMWConfiguration {
169 root_config: PathBuf,
170 settings: Vec<SettingValue>,
171 chain: Vec<ConfigChainEntry>,
172 indexed_content: HashSet<String>,
173 indexed_groundcover: HashSet<String>,
174 indexed_archives: HashSet<String>,
175 indexed_data_dirs: HashSet<PathBuf>,
176 indexed_game_setting_last: RefCell<HashMap<String, usize>>,
177 indexed_game_setting_order: RefCell<Vec<usize>>,
178 game_setting_indexes_dirty: Cell<bool>,
179}
180
181#[derive(Debug, Clone, Eq, PartialEq)]
182pub enum ConfigChainStatus {
183 Loaded,
184 SkippedMissing,
185}
186
187#[derive(Debug, Clone, Eq, PartialEq)]
188pub struct ConfigChainEntry {
189 path: PathBuf,
190 depth: usize,
191 status: ConfigChainStatus,
192}
193
194impl ConfigChainEntry {
195 #[must_use]
196 pub fn path(&self) -> &Path {
197 &self.path
198 }
199
200 #[must_use]
201 pub fn depth(&self) -> usize {
202 self.depth
203 }
204
205 #[must_use]
206 pub fn status(&self) -> &ConfigChainStatus {
207 &self.status
208 }
209}
210
211impl OpenMWConfiguration {
212 fn rebuild_indexes(&mut self) {
213 self.indexed_content.clear();
214 self.indexed_groundcover.clear();
215 self.indexed_archives.clear();
216 self.indexed_data_dirs.clear();
217
218 for setting in &self.settings {
219 match setting {
220 SettingValue::ContentFile(file) => {
221 self.indexed_content.insert(file.value().clone());
222 }
223 SettingValue::Groundcover(file) => {
224 self.indexed_groundcover.insert(file.value().clone());
225 }
226 SettingValue::BethArchive(file) => {
227 self.indexed_archives.insert(file.value().clone());
228 }
229 SettingValue::DataDirectory(dir) => {
230 self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
231 }
232 _ => {}
233 }
234 }
235
236 self.mark_game_setting_indexes_dirty();
237 }
238
239 fn mark_game_setting_indexes_dirty(&self) {
240 self.game_setting_indexes_dirty.set(true);
241 self.indexed_game_setting_last.borrow_mut().clear();
242 self.indexed_game_setting_order.borrow_mut().clear();
243 }
244
245 fn ensure_game_setting_indexes(&self) {
246 if !self.game_setting_indexes_dirty.get() {
247 return;
248 }
249
250 let mut last = HashMap::new();
251 for (index, setting) in self.settings.iter().enumerate() {
252 if let SettingValue::GameSetting(game_setting) = setting {
253 last.insert(game_setting.key().clone(), index);
254 }
255 }
256
257 let mut seen = HashSet::new();
258 let mut order = Vec::new();
259 for (index, setting) in self.settings.iter().enumerate().rev() {
260 if let SettingValue::GameSetting(game_setting) = setting
261 && seen.insert(game_setting.key())
262 {
263 order.push(index);
264 }
265 }
266
267 *self.indexed_game_setting_last.borrow_mut() = last;
268 *self.indexed_game_setting_order.borrow_mut() = order;
269 self.game_setting_indexes_dirty.set(false);
270 }
271
272 pub fn from_env() -> Result<Self, ConfigError> {
282 if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
283 let explicit_path = util::expand_leading_tilde(&explicit_path);
284
285 if explicit_path.as_os_str().is_empty() {
286 return Err(ConfigError::NotFileOrDirectory(explicit_path));
287 } else if explicit_path.is_absolute() {
288 return Self::new(Some(explicit_path));
289 } else if explicit_path.is_relative() {
290 return Self::new(Some(std::fs::canonicalize(explicit_path)?));
291 }
292 return Err(ConfigError::NotFileOrDirectory(explicit_path));
293 } else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
294 let path_list = if cfg!(windows) {
295 path_list.split(';')
296 } else {
297 path_list.split(':')
298 };
299
300 for dir in path_list {
301 let dir = util::expand_leading_tilde(dir);
302
303 if dir.join("openmw.cfg").exists() {
304 return Self::new(Some(dir));
305 }
306 }
307 }
308
309 Self::new(None)
310 }
311
312 pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
328 let mut config = OpenMWConfiguration::default();
329 let root_config = match path {
330 Some(path) => util::input_config_path(path)?,
331 None => crate::try_default_config_path()?.join("openmw.cfg"),
332 };
333
334 config.root_config = root_config;
335
336 if let Err(error) = config.load(&config.root_config.clone()) {
337 Err(error)
338 } else {
339 if let Some(dir) = config.data_local() {
340 let path = dir.parsed();
341
342 let path_meta = metadata(path);
343 if path_meta.is_err()
344 && let Err(error) = create_dir_all(path)
345 {
346 util::debug_log(&format!(
347 "WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
348 path.display()
349 ));
350 }
351
352 config
353 .settings
354 .push(SettingValue::DataDirectory(dir.clone()));
355 }
356
357 if let Some(setting) = config.resources() {
358 let dir = setting.parsed();
359
360 let engine_vfs = DirectorySetting::new(
361 dir.join("vfs").to_string_lossy().to_string(),
362 setting.meta.source_config.clone(),
363 &mut setting.meta.comment.clone(),
364 );
365
366 config
367 .settings
368 .insert(0, SettingValue::DataDirectory(engine_vfs));
369 }
370
371 util::debug_log(&format!("{:#?}", config.settings));
372
373 Ok(config)
374 }
375 }
376
377 #[must_use]
381 pub fn root_config_file(&self) -> &std::path::Path {
382 &self.root_config
383 }
384
385 #[must_use]
392 pub fn root_config_dir(&self) -> PathBuf {
393 self.root_config
394 .parent()
395 .expect("root_config has no parent directory")
396 .to_path_buf()
397 }
398
399 #[must_use]
400 pub fn is_user_config(&self) -> bool {
401 self.root_config_dir() == self.user_config_path()
402 }
403
404 pub fn user_config(self) -> Result<Self, ConfigError> {
407 let user_path = self.user_config_path();
408 if self.root_config_dir() == user_path {
409 Ok(self)
410 } else {
411 Self::new(Some(user_path))
412 }
413 }
414
415 pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
418 let user_path = self.user_config_path();
419 if self.root_config_dir() == user_path {
420 Ok(self.clone())
421 } else {
422 Self::new(Some(user_path))
423 }
424 }
425
426 #[must_use]
442 pub fn user_config_path(&self) -> PathBuf {
443 self.sub_configs()
444 .map(|setting| setting.parsed().to_path_buf())
445 .last()
446 .unwrap_or_else(|| self.root_config_dir())
447 }
448
449 impl_singleton_setting! {
450 UserData => {
451 get: userdata,
452 set: set_userdata,
453 in_type: DirectorySetting
454 },
455 Resources => {
456 get: resources,
457 set: set_resources,
458 in_type: DirectorySetting
459 },
460 DataLocal => {
461 get: data_local,
462 set: set_data_local,
463 in_type: DirectorySetting
464 },
465 Encoding => {
466 get: encoding,
467 set: set_encoding,
468 in_type: EncodingSetting
469 }
470 }
471
472 pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
476 self.settings.iter().filter_map(|setting| match setting {
477 SettingValue::ContentFile(plugin) => Some(plugin),
478 _ => None,
479 })
480 }
481
482 #[must_use]
484 pub fn has_content_file(&self, file_name: &str) -> bool {
485 self.indexed_content.contains(file_name)
486 }
487
488 #[must_use]
490 pub fn has_groundcover_file(&self, file_name: &str) -> bool {
491 self.indexed_groundcover.contains(file_name)
492 }
493
494 #[must_use]
496 pub fn has_archive_file(&self, file_name: &str) -> bool {
497 self.indexed_archives.contains(file_name)
498 }
499
500 #[must_use]
505 pub fn has_data_dir(&self, file_name: &str) -> bool {
506 let query = if file_name.contains(['/', '\\']) {
507 PathBuf::from(file_name.replace(['/', '\\'], std::path::MAIN_SEPARATOR_STR))
508 } else {
509 PathBuf::from(file_name)
510 };
511 self.indexed_data_dirs.contains(&query)
512 }
513
514 pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
517 let duplicate = self.settings.iter().find_map(|setting| match setting {
518 SettingValue::ContentFile(plugin) => {
519 if plugin.value() == content_file {
520 Some(plugin)
521 } else {
522 None
523 }
524 }
525 _ => None,
526 });
527
528 if let Some(duplicate) = duplicate {
529 bail_config!(
530 content_already_defined,
531 duplicate.value().to_owned(),
532 duplicate.meta().source_config
533 )
534 }
535
536 self.settings
537 .push(SettingValue::ContentFile(FileSetting::new(
538 content_file,
539 &self.user_config_path().join("openmw.cfg"),
540 &mut String::default(),
541 )));
542 self.rebuild_indexes();
543
544 Ok(())
545 }
546
547 pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
549 self.settings.iter().filter_map(|setting| match setting {
550 SettingValue::Groundcover(grass) => Some(grass),
551 _ => None,
552 })
553 }
554
555 pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
558 let duplicate = self.settings.iter().find_map(|setting| match setting {
559 SettingValue::Groundcover(plugin) => {
560 if plugin.value() == content_file {
561 Some(plugin)
562 } else {
563 None
564 }
565 }
566 _ => None,
567 });
568
569 if let Some(duplicate) = duplicate {
570 bail_config!(
571 groundcover_already_defined,
572 duplicate.value().to_owned(),
573 duplicate.meta().source_config
574 )
575 }
576
577 self.settings
578 .push(SettingValue::Groundcover(FileSetting::new(
579 content_file,
580 &self.user_config_path().join("openmw.cfg"),
581 &mut String::default(),
582 )));
583 self.rebuild_indexes();
584
585 Ok(())
586 }
587
588 pub fn remove_content_file(&mut self, file_name: &str) {
590 self.clear_matching_internal(|setting| match setting {
591 SettingValue::ContentFile(existing_file) => existing_file == file_name,
592 _ => false,
593 });
594 self.rebuild_indexes();
595 }
596
597 pub fn remove_groundcover_file(&mut self, file_name: &str) {
599 self.clear_matching_internal(|setting| match setting {
600 SettingValue::Groundcover(existing_file) => existing_file == file_name,
601 _ => false,
602 });
603 self.rebuild_indexes();
604 }
605
606 pub fn remove_archive_file(&mut self, file_name: &str) {
608 self.clear_matching_internal(|setting| match setting {
609 SettingValue::BethArchive(existing_file) => existing_file == file_name,
610 _ => false,
611 });
612 self.rebuild_indexes();
613 }
614
615 pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
617 self.clear_matching_internal(|setting| match setting {
618 SettingValue::DataDirectory(existing_data_dir) => {
619 existing_data_dir.parsed() == data_dir
620 || existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
621 }
622 _ => false,
623 });
624 self.rebuild_indexes();
625 }
626
627 pub fn add_data_directory(&mut self, dir: &Path) {
629 self.settings
630 .push(SettingValue::DataDirectory(DirectorySetting::new(
631 dir.to_string_lossy(),
632 self.user_config_path().join("openmw.cfg"),
633 &mut String::default(),
634 )));
635 self.rebuild_indexes();
636 }
637
638 pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
641 let duplicate = self.settings.iter().find_map(|setting| match setting {
642 SettingValue::BethArchive(archive) => {
643 if archive.value() == archive_file {
644 Some(archive)
645 } else {
646 None
647 }
648 }
649 _ => None,
650 });
651
652 if let Some(duplicate) = duplicate {
653 bail_config!(
654 duplicate_archive_file,
655 duplicate.value().to_owned(),
656 duplicate.meta().source_config
657 )
658 }
659
660 self.settings
661 .push(SettingValue::BethArchive(FileSetting::new(
662 archive_file,
663 &self.user_config_path().join("openmw.cfg"),
664 &mut String::default(),
665 )));
666 self.rebuild_indexes();
667
668 Ok(())
669 }
670
671 pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
673 self.settings.iter().filter_map(|setting| match setting {
674 SettingValue::BethArchive(archive) => Some(archive),
675 _ => None,
676 })
677 }
678
679 pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
683 self.clear_matching_internal(|setting| matches!(setting, SettingValue::ContentFile(_)));
684
685 if let Some(plugins) = plugins {
686 let cfg_path = self.user_config_path().join("openmw.cfg");
687 let mut empty = String::default();
688 for plugin in plugins {
689 self.settings
690 .push(SettingValue::ContentFile(FileSetting::new(
691 &plugin, &cfg_path, &mut empty,
692 )));
693 }
694 }
695
696 self.rebuild_indexes();
697 }
698
699 pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
703 self.clear_matching_internal(|setting| matches!(setting, SettingValue::BethArchive(_)));
704
705 if let Some(archives) = archives {
706 let cfg_path = self.user_config_path().join("openmw.cfg");
707 let mut empty = String::default();
708 for archive in archives {
709 self.settings
710 .push(SettingValue::BethArchive(FileSetting::new(
711 &archive, &cfg_path, &mut empty,
712 )));
713 }
714 }
715
716 self.rebuild_indexes();
717 }
718
719 pub fn settings_matching<'a, P>(
721 &'a self,
722 predicate: P,
723 ) -> impl Iterator<Item = &'a SettingValue>
724 where
725 P: Fn(&SettingValue) -> bool + 'a,
726 {
727 self.settings.iter().filter(move |s| predicate(s))
728 }
729
730 fn clear_matching_internal<P>(&mut self, predicate: P)
732 where
733 P: Fn(&SettingValue) -> bool,
734 {
735 self.settings.retain(|s| !predicate(s));
736 }
737
738 pub fn clear_matching<P>(&mut self, predicate: P)
740 where
741 P: Fn(&SettingValue) -> bool,
742 {
743 self.clear_matching_internal(predicate);
744 self.rebuild_indexes();
745 }
746
747 pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
751 self.clear_matching_internal(|setting| matches!(setting, SettingValue::DataDirectory(_)));
752
753 if let Some(dirs) = dirs {
754 let cfg_path = self.user_config_path().join("openmw.cfg");
755 let mut empty = String::default();
756
757 for dir in dirs {
758 self.settings
759 .push(SettingValue::DataDirectory(DirectorySetting::new(
760 dir.to_string_lossy(),
761 cfg_path.clone(),
762 &mut empty,
763 )));
764 }
765 }
766
767 self.rebuild_indexes();
768 }
769
770 pub fn set_game_setting(
777 &mut self,
778 base_value: &str,
779 config_path: Option<PathBuf>,
780 comment: &mut String,
781 ) -> Result<(), ConfigError> {
782 let new_setting = GameSettingType::try_from((
783 base_value.to_owned(),
784 config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
785 comment,
786 ))?;
787
788 self.settings.push(SettingValue::GameSetting(new_setting));
789 self.rebuild_indexes();
790
791 Ok(())
792 }
793
794 pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
802 self.clear_matching_internal(|setting| matches!(setting, SettingValue::GameSetting(_)));
803
804 if let Some(settings) = settings {
805 let cfg_path = self.user_config_path().join("openmw.cfg");
806 let mut empty = String::default();
807
808 for setting in settings {
809 let parsed =
810 match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
811 Ok(parsed) => parsed,
812 Err(error) => {
813 self.rebuild_indexes();
814 return Err(error);
815 }
816 };
817
818 self.settings.push(SettingValue::GameSetting(parsed));
819 }
820 }
821
822 self.rebuild_indexes();
823
824 Ok(())
825 }
826
827 pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
832 self.settings.iter().filter_map(|setting| match setting {
833 SettingValue::SubConfiguration(subconfig) => Some(subconfig),
834 _ => None,
835 })
836 }
837
838 pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
843 self.chain.iter()
844 }
845
846 pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
863 self.ensure_game_setting_indexes();
864 let order = self.indexed_game_setting_order.borrow().clone();
865 order
866 .into_iter()
867 .filter_map(move |index| match &self.settings[index] {
868 SettingValue::GameSetting(setting) => Some(setting),
869 _ => None,
870 })
871 }
872
873 #[must_use]
877 pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
878 self.ensure_game_setting_indexes();
879 self.indexed_game_setting_last
880 .borrow()
881 .get(key)
882 .and_then(|index| match &self.settings[*index] {
883 SettingValue::GameSetting(setting) => Some(setting),
884 _ => None,
885 })
886 }
887
888 pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
897 self.settings.iter().filter_map(|setting| match setting {
898 SettingValue::DataDirectory(data_dir) => Some(data_dir),
899 _ => None,
900 })
901 }
902
903 const MAX_CONFIG_DEPTH: usize = 16;
904
905 #[allow(clippy::too_many_lines)]
906 fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
907 let mut pending_configs = VecDeque::new();
908 pending_configs.push_back((root_config.to_path_buf(), 0usize));
909
910 let mut seen_content: HashSet<String> = self
911 .settings
912 .iter()
913 .filter_map(|setting| match setting {
914 SettingValue::ContentFile(file) => Some(file.value().clone()),
915 _ => None,
916 })
917 .collect();
918 let mut seen_groundcover: HashSet<String> = self
919 .settings
920 .iter()
921 .filter_map(|setting| match setting {
922 SettingValue::Groundcover(file) => Some(file.value().clone()),
923 _ => None,
924 })
925 .collect();
926 let mut seen_archives: HashSet<String> = self
927 .settings
928 .iter()
929 .filter_map(|setting| match setting {
930 SettingValue::BethArchive(file) => Some(file.value().clone()),
931 _ => None,
932 })
933 .collect();
934
935 while let Some((config_dir, depth)) = pending_configs.pop_front() {
936 if depth > Self::MAX_CONFIG_DEPTH {
937 bail_config!(max_depth_exceeded, config_dir);
938 }
939
940 util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", config_dir.display()));
941
942 if !config_dir.exists() {
943 bail_config!(cannot_find, config_dir);
944 }
945
946 let cfg_file_path = if config_dir.is_dir() {
947 config_dir.join("openmw.cfg")
948 } else {
949 config_dir
950 };
951
952 self.chain.push(ConfigChainEntry {
953 path: cfg_file_path.clone(),
954 depth,
955 status: ConfigChainStatus::Loaded,
956 });
957
958 let lines = read_to_string(&cfg_file_path)?;
959
960 let mut queued_comment = String::new();
961 let mut sub_configs: Vec<(String, String)> = Vec::new();
962
963 for (index, line) in lines.lines().enumerate() {
964 let line_no = index + 1;
965 let trimmed = line.trim();
966
967 if trimmed.is_empty() {
968 queued_comment.push('\n');
969 continue;
970 } else if trimmed.starts_with('#') {
971 queued_comment.push_str(line);
972 queued_comment.push('\n');
973 continue;
974 }
975
976 let Some((key, value)) = trimmed.split_once('=') else {
977 bail_config!(invalid_line, trimmed.into(), cfg_file_path.clone(), line_no);
978 };
979
980 let key = key.trim();
981 let value = value.trim();
982
983 match key {
984 "content" => {
985 if !seen_content.insert(value.to_owned()) {
986 bail_config!(
987 duplicate_content_file,
988 value.to_owned(),
989 cfg_file_path,
990 line_no
991 );
992 }
993 self.settings
994 .push(SettingValue::ContentFile(FileSetting::new(
995 value,
996 &cfg_file_path,
997 &mut queued_comment,
998 )));
999 }
1000 "groundcover" => {
1001 if !seen_groundcover.insert(value.to_owned()) {
1002 bail_config!(
1003 duplicate_groundcover_file,
1004 value.to_owned(),
1005 cfg_file_path,
1006 line_no
1007 );
1008 }
1009 self.settings
1010 .push(SettingValue::Groundcover(FileSetting::new(
1011 value,
1012 &cfg_file_path,
1013 &mut queued_comment,
1014 )));
1015 }
1016 "fallback-archive" => {
1017 if !seen_archives.insert(value.to_owned()) {
1018 bail_config!(
1019 duplicate_archive_file,
1020 value.to_owned(),
1021 cfg_file_path,
1022 line_no
1023 );
1024 }
1025 self.settings
1026 .push(SettingValue::BethArchive(FileSetting::new(
1027 value,
1028 &cfg_file_path,
1029 &mut queued_comment,
1030 )));
1031 }
1032 "fallback" => {
1033 let game_setting = GameSettingType::try_from((
1034 value.to_owned(),
1035 cfg_file_path.clone(),
1036 &mut queued_comment,
1037 ))
1038 .map_err(|error| match error {
1039 ConfigError::InvalidGameSetting {
1040 value, config_path, ..
1041 } => ConfigError::InvalidGameSetting {
1042 value,
1043 config_path,
1044 line: Some(line_no),
1045 },
1046 _ => error,
1047 })?;
1048
1049 self.settings.push(SettingValue::GameSetting(game_setting));
1050 }
1051 "encoding" => {
1052 let encoding = EncodingSetting::try_from((
1053 value.to_owned(),
1054 &cfg_file_path,
1055 &mut queued_comment,
1056 ))
1057 .map_err(|error| match error {
1058 ConfigError::BadEncoding {
1059 value, config_path, ..
1060 } => ConfigError::BadEncoding {
1061 value,
1062 config_path,
1063 line: Some(line_no),
1064 },
1065 _ => error,
1066 })?;
1067 self.set_encoding(Some(encoding));
1068 }
1069 "config" => {
1070 sub_configs.push((value.to_owned(), std::mem::take(&mut queued_comment)));
1071 }
1072 "data" => {
1073 insert_dir_setting!(
1074 self,
1075 DataDirectory,
1076 value,
1077 cfg_file_path.clone(),
1078 &mut queued_comment
1079 );
1080 }
1081 "resources" => {
1082 insert_dir_setting!(
1083 self,
1084 Resources,
1085 value,
1086 cfg_file_path.clone(),
1087 &mut queued_comment
1088 );
1089 }
1090 "user-data" => {
1091 insert_dir_setting!(
1092 self,
1093 UserData,
1094 value,
1095 cfg_file_path.clone(),
1096 &mut queued_comment
1097 );
1098 }
1099 "data-local" => {
1100 insert_dir_setting!(
1101 self,
1102 DataLocal,
1103 value,
1104 cfg_file_path.clone(),
1105 &mut queued_comment
1106 );
1107 }
1108 "replace" => match value.to_ascii_lowercase().as_str() {
1109 "content" => {
1110 self.clear_matching_internal(|s| {
1111 matches!(s, SettingValue::ContentFile(_))
1112 });
1113 seen_content.clear();
1114 }
1115 "data" => {
1116 self.clear_matching_internal(|s| {
1117 matches!(s, SettingValue::DataDirectory(_))
1118 });
1119 }
1120 "fallback" => {
1121 self.clear_matching_internal(|s| {
1122 matches!(s, SettingValue::GameSetting(_))
1123 });
1124 }
1125 "fallback-archives" => {
1126 self.clear_matching_internal(|s| {
1127 matches!(s, SettingValue::BethArchive(_))
1128 });
1129 seen_archives.clear();
1130 }
1131 "groundcover" => {
1132 self.clear_matching_internal(|s| {
1133 matches!(s, SettingValue::Groundcover(_))
1134 });
1135 seen_groundcover.clear();
1136 }
1137 "data-local" => self.set_data_local(None),
1138 "resources" => self.set_resources(None),
1139 "user-data" => self.set_userdata(None),
1140 "config" => {
1141 self.settings.clear();
1142 seen_content.clear();
1143 seen_groundcover.clear();
1144 seen_archives.clear();
1145 sub_configs.clear();
1146 pending_configs.clear();
1147 }
1148 _ => {}
1149 },
1150 _ => {
1151 let setting =
1152 GenericSetting::new(key, value, &cfg_file_path, &mut queued_comment);
1153 self.settings.push(SettingValue::Generic(setting));
1154 }
1155 }
1156 }
1157
1158 for (subconfig_path, mut subconfig_comment) in sub_configs {
1159 let mut comment = std::mem::take(&mut subconfig_comment);
1160 let setting =
1161 DirectorySetting::new(subconfig_path, cfg_file_path.clone(), &mut comment);
1162 let subconfig_file = setting.parsed().join("openmw.cfg");
1163
1164 if std::fs::metadata(&subconfig_file).is_ok() {
1165 self.settings.push(SettingValue::SubConfiguration(setting));
1166 pending_configs.push_back((subconfig_file, depth + 1));
1167 } else {
1168 self.chain.push(ConfigChainEntry {
1169 path: subconfig_file,
1170 depth: depth + 1,
1171 status: ConfigChainStatus::SkippedMissing,
1172 });
1173 util::debug_log_lazy(|| {
1174 format!(
1175 "Skipping parsing of {} as this directory does not actually contain an openmw.cfg!",
1176 setting.parsed().display(),
1177 )
1178 });
1179 }
1180 }
1181 }
1182
1183 self.rebuild_indexes();
1184
1185 Ok(())
1186 }
1187
1188 fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1189 use std::io::Write;
1190 use std::time::{SystemTime, UNIX_EPOCH};
1191
1192 let parent = path
1193 .parent()
1194 .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1195
1196 let nonce = SystemTime::now()
1197 .duration_since(UNIX_EPOCH)
1198 .map_or(0, |d| d.as_nanos());
1199 let tmp_path = parent.join(format!(
1200 ".openmw-config-tmp-{}-{}",
1201 std::process::id(),
1202 nonce
1203 ));
1204
1205 let mut file = std::fs::OpenOptions::new()
1206 .write(true)
1207 .create_new(true)
1208 .open(&tmp_path)?;
1209
1210 file.write_all(config_string.as_bytes())?;
1211 file.sync_all()?;
1212
1213 #[cfg(windows)]
1214 {
1215 if path.exists() {
1216 std::fs::remove_file(path)?;
1217 }
1218 }
1219
1220 std::fs::rename(&tmp_path, path)?;
1221
1222 Ok(())
1223 }
1224
1225 pub fn save_user(&self) -> Result<(), ConfigError> {
1235 let target_dir = self.user_config_path();
1236 let cfg_path = target_dir.join("openmw.cfg");
1237
1238 if !util::is_writable(&cfg_path) {
1239 bail_config!(not_writable, &cfg_path);
1240 }
1241
1242 let mut user_settings_string = String::new();
1243
1244 for user_setting in
1245 self.settings_matching(|setting| setting.meta().source_config == cfg_path)
1246 {
1247 user_settings_string.push_str(&user_setting.to_string());
1248 }
1249
1250 Self::write_config(&user_settings_string, &cfg_path)?;
1251
1252 Ok(())
1253 }
1254
1255 pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
1265 let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
1266 SettingValue::SubConfiguration(subconfig) => {
1267 subconfig.parsed() == target_dir
1268 || subconfig.original() == target_dir.to_string_lossy().as_ref()
1269 }
1270 _ => false,
1271 });
1272
1273 if !subconfig_is_loaded {
1274 bail_config!(subconfig_not_loaded, target_dir);
1275 }
1276
1277 let cfg_path = target_dir.join("openmw.cfg");
1278
1279 if !util::is_writable(&cfg_path) {
1280 bail_config!(not_writable, &cfg_path);
1281 }
1282
1283 let mut subconfig_settings_string = String::new();
1284
1285 for subconfig_setting in
1286 self.settings_matching(|setting| setting.meta().source_config == cfg_path)
1287 {
1288 subconfig_settings_string.push_str(&subconfig_setting.to_string());
1289 }
1290
1291 Self::write_config(&subconfig_settings_string, &cfg_path)?;
1292
1293 Ok(())
1294 }
1295}
1296
1297impl fmt::Display for OpenMWConfiguration {
1309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1310 self.settings
1311 .iter()
1312 .try_for_each(|setting| write!(f, "{setting}"))?;
1313
1314 writeln!(
1315 f,
1316 "# OpenMW-Config Serializer Version: {}",
1317 env!("CARGO_PKG_VERSION")
1318 )?;
1319
1320 Ok(())
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327 use std::io::Write;
1328 use std::sync::{
1329 Mutex, OnceLock,
1330 atomic::{AtomicU64, Ordering},
1331 };
1332
1333 fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
1338 let cfg = dir.join("openmw.cfg");
1339 let mut f = std::fs::File::create(&cfg).unwrap();
1340 f.write_all(contents.as_bytes()).unwrap();
1341 cfg
1342 }
1343
1344 fn temp_dir() -> PathBuf {
1345 static COUNTER: AtomicU64 = AtomicU64::new(0);
1350 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1351 let base = std::env::temp_dir().join(format!("openmw_cfg_test_{id}"));
1352 std::fs::create_dir_all(&base).unwrap();
1353 base
1354 }
1355
1356 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1357 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1358 LOCK.get_or_init(|| Mutex::new(()))
1359 .lock()
1360 .unwrap_or_else(std::sync::PoisonError::into_inner)
1361 }
1362
1363 fn load(cfg_contents: &str) -> OpenMWConfiguration {
1364 let dir = temp_dir();
1365 write_cfg(&dir, cfg_contents);
1366 OpenMWConfiguration::new(Some(dir)).unwrap()
1367 }
1368
1369 #[cfg(unix)]
1370 fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
1371 std::os::unix::fs::symlink(target, link).unwrap();
1372 }
1373
1374 #[test]
1379 fn test_content_files_empty_on_bare_config() {
1380 let config = load("");
1381 assert!(config.content_files_iter().next().is_none());
1382 }
1383
1384 #[test]
1385 fn test_content_files_parsed_in_order() {
1386 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
1387 let files: Vec<&String> = config
1388 .content_files_iter()
1389 .map(FileSetting::value)
1390 .collect();
1391 assert_eq!(
1392 files,
1393 vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
1394 );
1395 }
1396
1397 #[test]
1398 fn test_has_content_file_found() {
1399 let config = load("content=Morrowind.esm\n");
1400 assert!(config.has_content_file("Morrowind.esm"));
1401 }
1402
1403 #[test]
1404 fn test_has_content_file_not_found() {
1405 let config = load("content=Morrowind.esm\n");
1406 assert!(!config.has_content_file("Tribunal.esm"));
1407 }
1408
1409 #[test]
1410 fn test_duplicate_content_file_errors_on_load() {
1411 let dir = temp_dir();
1412 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1413 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1414 }
1415
1416 #[test]
1417 fn test_duplicate_content_file_error_reports_line_number() {
1418 let dir = temp_dir();
1419 write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1420
1421 let result = OpenMWConfiguration::new(Some(dir));
1422 assert!(matches!(
1423 result,
1424 Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
1425 ));
1426 }
1427
1428 #[test]
1429 fn test_add_content_file_appends() {
1430 let mut config = load("content=Morrowind.esm\n");
1431 config.add_content_file("MyMod.esp").unwrap();
1432 assert!(config.has_content_file("MyMod.esp"));
1433 }
1434
1435 #[test]
1436 fn test_add_duplicate_content_file_errors() {
1437 let mut config = load("content=Morrowind.esm\n");
1438 assert!(config.add_content_file("Morrowind.esm").is_err());
1439 }
1440
1441 #[test]
1442 fn test_add_content_file_source_config_is_cfg_file() {
1443 let dir = temp_dir();
1444 let cfg_path = write_cfg(&dir, "");
1445 let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
1446 config.add_content_file("Mod.esp").unwrap();
1447 let setting = config.content_files_iter().next().unwrap();
1448 assert_eq!(
1449 setting.meta().source_config,
1450 cfg_path,
1451 "source_config should be the openmw.cfg file, not a directory"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_remove_content_file() {
1457 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1458 config.remove_content_file("Morrowind.esm");
1459 assert!(!config.has_content_file("Morrowind.esm"));
1460 assert!(config.has_content_file("Tribunal.esm"));
1461 }
1462
1463 #[test]
1464 fn test_set_content_files_replaces_all() {
1465 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1466 config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
1467 assert!(!config.has_content_file("Morrowind.esm"));
1468 assert!(!config.has_content_file("Tribunal.esm"));
1469 assert!(config.has_content_file("NewMod.esp"));
1470 }
1471
1472 #[test]
1473 fn test_set_content_files_none_clears_all() {
1474 let mut config = load("content=Morrowind.esm\n");
1475 config.set_content_files(None);
1476 assert!(config.content_files_iter().next().is_none());
1477 }
1478
1479 #[test]
1484 fn test_fallback_archives_parsed() {
1485 let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1486 let archives: Vec<&String> = config
1487 .fallback_archives_iter()
1488 .map(FileSetting::value)
1489 .collect();
1490 assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
1491 }
1492
1493 #[test]
1494 fn test_has_archive_file() {
1495 let config = load("fallback-archive=Morrowind.bsa\n");
1496 assert!(config.has_archive_file("Morrowind.bsa"));
1497 assert!(!config.has_archive_file("Tribunal.bsa"));
1498 }
1499
1500 #[test]
1501 fn test_add_duplicate_archive_errors() {
1502 let mut config = load("fallback-archive=Morrowind.bsa\n");
1503 assert!(config.add_archive_file("Morrowind.bsa").is_err());
1504 }
1505
1506 #[test]
1507 fn test_duplicate_archive_error_reports_line_number() {
1508 let dir = temp_dir();
1509 write_cfg(
1510 &dir,
1511 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
1512 );
1513
1514 let result = OpenMWConfiguration::new(Some(dir));
1515 assert!(matches!(
1516 result,
1517 Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
1518 ));
1519 }
1520
1521 #[test]
1522 fn test_remove_archive_file() {
1523 let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1524 config.remove_archive_file("Morrowind.bsa");
1525 assert!(!config.has_archive_file("Morrowind.bsa"));
1526 assert!(config.has_archive_file("Tribunal.bsa"));
1527 }
1528
1529 #[test]
1534 fn test_groundcover_parsed() {
1535 let config = load("groundcover=GrassPlugin.esp\n");
1536 let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
1537 assert_eq!(grass, vec!["GrassPlugin.esp"]);
1538 }
1539
1540 #[test]
1541 fn test_has_groundcover_file() {
1542 let config = load("groundcover=Grass.esp\n");
1543 assert!(config.has_groundcover_file("Grass.esp"));
1544 assert!(!config.has_groundcover_file("Other.esp"));
1545 }
1546
1547 #[test]
1548 fn test_duplicate_groundcover_errors_on_load() {
1549 let dir = temp_dir();
1550 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1551 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1552 }
1553
1554 #[test]
1555 fn test_duplicate_groundcover_error_reports_line_number() {
1556 let dir = temp_dir();
1557 write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1558
1559 let result = OpenMWConfiguration::new(Some(dir));
1560 assert!(matches!(
1561 result,
1562 Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
1563 ));
1564 }
1565
1566 #[test]
1571 fn test_data_directories_absolute_paths_parsed() {
1572 let config = load("data=/absolute/path/to/data\n");
1573 assert!(
1574 config
1575 .data_directories_iter()
1576 .any(|d| d.parsed().ends_with("absolute/path/to/data"))
1577 );
1578 }
1579
1580 #[test]
1581 fn test_add_data_directory() {
1582 let mut config = load("");
1583 config.add_data_directory(Path::new("/some/data/dir"));
1584 assert!(config.has_data_dir("/some/data/dir"));
1585 }
1586
1587 #[test]
1588 fn test_set_data_directories_replaces_all() {
1589 let mut config = load("data=/old/dir\n");
1590 config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
1591 assert!(!config.has_data_dir("/old/dir"));
1592 assert!(config.has_data_dir("/new/dir"));
1593 }
1594
1595 #[test]
1596 fn test_remove_data_directory() {
1597 let mut config = load("data=/keep/me\n");
1598 config.add_data_directory(Path::new("/remove/me"));
1599 config.remove_data_directory(&PathBuf::from("/remove/me"));
1600 assert!(!config.has_data_dir("/remove/me"));
1601 assert!(config.has_data_dir("/keep/me"));
1602 }
1603
1604 #[test]
1609 fn test_game_settings_parsed() {
1610 let config = load("fallback=iMaxLevel,100\n");
1611 let setting = config.get_game_setting("iMaxLevel").unwrap();
1612 assert_eq!(setting.value(), "100");
1613 }
1614
1615 #[test]
1616 fn test_game_settings_last_wins() {
1617 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
1618 let setting = config.get_game_setting("iKey").unwrap();
1619 assert_eq!(setting.value(), "2");
1620 }
1621
1622 #[test]
1623 fn test_game_settings_deduplicates_by_key() {
1624 let config = load("fallback=iKey,1\nfallback=iKey,2\n");
1627 let results: Vec<_> = config
1628 .game_settings()
1629 .filter(|s| s.key() == "iKey")
1630 .collect();
1631 assert_eq!(
1632 results.len(),
1633 1,
1634 "game_settings() should deduplicate by key"
1635 );
1636 assert_eq!(results[0].value(), "2", "last-defined value should win");
1637 }
1638
1639 #[test]
1640 fn test_get_game_setting_missing_returns_none() {
1641 let config = load("fallback=iKey,1\n");
1642 assert!(config.get_game_setting("iMissing").is_none());
1643 }
1644
1645 #[test]
1646 fn test_game_setting_color_roundtrip() {
1647 let config = load("fallback=iSkyColor,100,149,237\n");
1648 let setting = config.get_game_setting("iSkyColor").unwrap();
1649 assert_eq!(setting.value(), "100,149,237");
1650 }
1651
1652 #[test]
1653 fn test_game_setting_float_roundtrip() {
1654 let config = load("fallback=fGravity,9.81\n");
1655 let setting = config.get_game_setting("fGravity").unwrap();
1656 assert_eq!(setting.value(), "9.81");
1657 }
1658
1659 #[test]
1660 fn test_invalid_game_setting_error_reports_line_number() {
1661 let dir = temp_dir();
1662 write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
1663
1664 let result = OpenMWConfiguration::new(Some(dir));
1665 assert!(matches!(
1666 result,
1667 Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
1668 ));
1669 }
1670
1671 #[test]
1676 fn test_encoding_parsed() {
1677 use crate::config::encodingsetting::EncodingType;
1678 let config = load("encoding=win1252\n");
1679 assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
1680 }
1681
1682 #[test]
1683 fn test_invalid_encoding_errors_on_load() {
1684 let dir = temp_dir();
1685 write_cfg(&dir, "encoding=utf8\n");
1686 assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1687 }
1688
1689 #[test]
1690 fn test_invalid_encoding_error_reports_line_number() {
1691 let dir = temp_dir();
1692 write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
1693
1694 let result = OpenMWConfiguration::new(Some(dir));
1695 assert!(matches!(
1696 result,
1697 Err(ConfigError::BadEncoding { line: Some(2), .. })
1698 ));
1699 }
1700
1701 #[test]
1706 fn test_replace_content_clears_prior_plugins() {
1707 let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
1708 assert!(!config.has_content_file("Old.esm"));
1709 assert!(config.has_content_file("New.esm"));
1710 }
1711
1712 #[test]
1713 fn test_replace_data_clears_prior_dirs() {
1714 let config = load("data=/old\nreplace=data\ndata=/new\n");
1715 assert!(!config.has_data_dir("/old"));
1716 assert!(config.has_data_dir("/new"));
1717 }
1718
1719 #[test]
1720 fn test_replace_keeps_comment_adjacency() {
1721 let config = load("content=Old.esm\nreplace=content\n\n# keep me\ncontent=New.esm\n");
1722 let output = config.to_string();
1723
1724 assert!(!output.contains("Old.esm"));
1725 assert!(output.contains("# keep me\ncontent=New.esm"));
1726 }
1727
1728 #[test]
1733 fn test_display_contains_version_comment() {
1734 let config = load("content=Morrowind.esm\n");
1735 let output = config.to_string();
1736 assert!(
1737 output.contains("# OpenMW-Config Serializer Version:"),
1738 "Display should include version comment"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_display_preserves_content_entries() {
1744 let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1745 let output = config.to_string();
1746 assert!(output.contains("content=Morrowind.esm"));
1747 assert!(output.contains("content=Tribunal.esm"));
1748 }
1749
1750 #[test]
1751 fn test_display_preserves_comments() {
1752 let config = load("# This is a comment\ncontent=Morrowind.esm\n");
1753 let output = config.to_string();
1754 assert!(output.contains("# This is a comment"));
1755 }
1756
1757 #[test]
1762 fn test_generic_setting_preserved() {
1763 let config = load("some-unknown-key=some-value\n");
1764 let output = config.to_string();
1765 assert!(output.contains("some-unknown-key=some-value"));
1766 }
1767
1768 #[test]
1773 fn test_save_user_round_trips_content_files() {
1774 let dir = temp_dir();
1775 write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
1776 let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
1777 config.add_content_file("Bloodmoon.esm").unwrap();
1778 config.save_user().unwrap();
1779
1780 let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
1781 let files: Vec<&String> = reloaded
1782 .content_files_iter()
1783 .map(FileSetting::value)
1784 .collect();
1785 assert!(files.contains(&&"Morrowind.esm".to_string()));
1786 assert!(files.contains(&&"Bloodmoon.esm".to_string()));
1787 }
1788
1789 #[test]
1790 fn test_save_user_not_writable_returns_error() {
1791 #[cfg(unix)]
1793 {
1794 use std::os::unix::fs::PermissionsExt;
1795 let dir = temp_dir();
1796 write_cfg(&dir, "content=Morrowind.esm\n");
1797 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
1798
1799 let cfg_path = dir.join("openmw.cfg");
1801 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1802
1803 let result = config.save_user();
1804 std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1806
1807 assert!(
1808 matches!(result, Err(ConfigError::NotWritable(_))),
1809 "expected NotWritable, got {result:?}"
1810 );
1811 }
1812 }
1813
1814 #[test]
1819 fn test_save_subconfig_rejects_unloaded_path() {
1820 let dir = temp_dir();
1821 write_cfg(&dir, "content=Morrowind.esm\n");
1822 let config = OpenMWConfiguration::new(Some(dir)).unwrap();
1823
1824 let fake_dir = temp_dir();
1825 let result = config.save_subconfig(&fake_dir);
1826 assert!(
1827 matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
1828 "expected SubconfigNotLoaded, got {result:?}"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_save_subconfig_round_trips_settings() {
1834 let root_dir = temp_dir();
1835 let sub_dir = temp_dir();
1836 write_cfg(&sub_dir, "content=Plugin.esp\n");
1837 write_cfg(
1838 &root_dir,
1839 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
1840 );
1841
1842 let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
1843 config.add_content_file("NewPlugin.esp").unwrap();
1844 config.save_subconfig(&sub_dir).unwrap();
1845
1846 let sub_cfg = sub_dir.join("openmw.cfg");
1847 let saved = std::fs::read_to_string(sub_cfg).unwrap();
1848 assert!(
1849 saved.contains("content=Plugin.esp"),
1850 "sub-config content preserved"
1851 );
1852 }
1853
1854 #[test]
1859 fn test_from_env_openmw_config_dir() {
1860 let _guard = env_lock();
1861 let dir = temp_dir();
1862 write_cfg(&dir, "content=Morrowind.esm\n");
1863
1864 unsafe { std::env::set_var("OPENMW_CONFIG_DIR", &dir) };
1867 let config = OpenMWConfiguration::from_env().unwrap();
1868 unsafe { std::env::remove_var("OPENMW_CONFIG_DIR") };
1869
1870 assert!(config.has_content_file("Morrowind.esm"));
1871 }
1872
1873 #[test]
1874 fn test_from_env_openmw_config_file() {
1875 let _guard = env_lock();
1876 let dir = temp_dir();
1877 let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
1878
1879 unsafe { std::env::set_var("OPENMW_CONFIG", &cfg) };
1880 let config = OpenMWConfiguration::from_env().unwrap();
1881 unsafe { std::env::remove_var("OPENMW_CONFIG") };
1882
1883 assert!(config.has_content_file("Tribunal.esm"));
1884 }
1885
1886 #[test]
1891 fn test_error_duplicate_archive_file() {
1892 let dir = temp_dir();
1894 write_cfg(
1895 &dir,
1896 "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
1897 );
1898 let result = OpenMWConfiguration::new(Some(dir));
1899 assert!(matches!(
1900 result,
1901 Err(ConfigError::DuplicateArchiveFile { .. })
1902 ));
1903 }
1904
1905 #[test]
1906 fn test_error_cannot_add_groundcover_file() {
1907 let mut config = load("groundcover=GrassPlugin.esp\n");
1908 let result = config.add_groundcover_file("GrassPlugin.esp");
1909 assert!(matches!(
1910 result,
1911 Err(ConfigError::CannotAddGroundcoverFile { .. })
1912 ));
1913 }
1914
1915 #[test]
1916 fn test_error_cannot_find() {
1917 let result =
1918 OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
1919 assert!(matches!(
1920 result,
1921 Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
1922 ));
1923 }
1924
1925 #[test]
1926 fn test_error_io_from_conversion() {
1927 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1928 let config_err: ConfigError = io_err.into();
1929 assert!(matches!(config_err, ConfigError::Io(_)));
1930 }
1931
1932 #[test]
1933 fn test_error_invalid_line() {
1934 let result = OpenMWConfiguration::new(Some({
1936 let dir = temp_dir();
1937 write_cfg(&dir, "this_has_no_equals_sign\n");
1938 dir
1939 }));
1940 assert!(matches!(
1941 result,
1942 Err(ConfigError::InvalidLine { line: Some(1), .. })
1943 ));
1944 }
1945
1946 #[test]
1947 fn test_error_max_depth_exceeded() {
1948 let dir = temp_dir();
1950 write_cfg(&dir, &format!("config={}\n", dir.display()));
1951 let result = OpenMWConfiguration::new(Some(dir));
1952 assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
1953 }
1954
1955 #[test]
1956 fn test_error_max_depth_exceeded_for_circular_chain() {
1957 let a = temp_dir();
1958 let b = temp_dir();
1959
1960 write_cfg(&a, &format!("config={}\n", b.display()));
1961 write_cfg(&b, &format!("config={}\n", a.display()));
1962
1963 let result = OpenMWConfiguration::new(Some(a));
1964 assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
1965 }
1966
1967 #[cfg(unix)]
1968 #[test]
1969 fn test_symlinked_config_dir_loads_like_real_path() {
1970 let real_dir = temp_dir();
1971 write_cfg(&real_dir, "content=Morrowind.esm\n");
1972
1973 let link_parent = temp_dir();
1974 let link_path = link_parent.join("symlinked-config");
1975 if link_path.exists() {
1976 let _ = std::fs::remove_file(&link_path);
1977 let _ = std::fs::remove_dir_all(&link_path);
1978 }
1979 symlink_dir(&real_dir, &link_path);
1980
1981 let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
1982
1983 assert!(config.has_content_file("Morrowind.esm"));
1984 assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
1985 assert_eq!(config.root_config_dir(), link_path);
1986 }
1987
1988 #[test]
1993 fn test_settings_matching_filters_correctly() {
1994 let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
1995 let content_count = config
1996 .settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
1997 .count();
1998 assert_eq!(content_count, 1);
1999 }
2000
2001 #[test]
2002 fn test_clear_matching_removes_entries() {
2003 let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2004 config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
2005 assert_eq!(config.content_files_iter().count(), 0);
2006 }
2007
2008 #[test]
2013 fn test_sub_configs_iteration() {
2014 let root_dir = temp_dir();
2015 let sub_dir = temp_dir();
2016 write_cfg(&sub_dir, "content=Plugin.esp\n");
2017 write_cfg(
2018 &root_dir,
2019 &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
2020 );
2021
2022 let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
2023 assert_eq!(config.sub_configs().count(), 1);
2024 assert!(
2025 config.has_content_file("Plugin.esp"),
2026 "sub-config content visible in root"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_config_chain_priority_order_for_data_lists_matches_openmw_docs_example() {
2032 let dir1 = temp_dir();
2033 let dir2 = temp_dir();
2034 let dir3 = temp_dir();
2035 let dir4 = temp_dir();
2036
2037 write_cfg(
2038 &dir1,
2039 &format!(
2040 "data=root-a\nconfig={}\nconfig={}\n",
2041 dir2.display(),
2042 dir3.display()
2043 ),
2044 );
2045 write_cfg(
2046 &dir2,
2047 &format!("data=branch-a\nconfig={}\n", dir4.display()),
2048 );
2049 write_cfg(&dir3, "data=sibling-a\n");
2050 write_cfg(&dir4, "data=leaf-a\n");
2051
2052 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
2053 let actual: Vec<String> = config
2054 .data_directories_iter()
2055 .map(|setting| setting.original().clone())
2056 .collect();
2057
2058 assert_eq!(actual, vec!["root-a", "branch-a", "sibling-a", "leaf-a"]);
2059 }
2060
2061 #[test]
2062 fn test_replace_data_preserves_docs_priority_order_in_branching_chain() {
2063 let dir1 = temp_dir();
2064 let dir2 = temp_dir();
2065 let dir3 = temp_dir();
2066 let dir4 = temp_dir();
2067
2068 write_cfg(
2069 &dir1,
2070 &format!(
2071 "data=root-a\nconfig={}\nconfig={}\n",
2072 dir2.display(),
2073 dir3.display()
2074 ),
2075 );
2076 write_cfg(
2077 &dir2,
2078 &format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
2079 );
2080 write_cfg(&dir3, "data=sibling-a\n");
2081 write_cfg(&dir4, "data=leaf-a\n");
2082
2083 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
2084 let actual: Vec<String> = config
2085 .data_directories_iter()
2086 .map(|setting| setting.original().clone())
2087 .collect();
2088
2089 assert_eq!(actual, vec!["branch-a", "sibling-a", "leaf-a"]);
2090 }
2091
2092 #[test]
2093 fn test_config_chain_priority_order_for_content_lists_matches_openmw_docs_example() {
2094 let dir1 = temp_dir();
2095 let dir2 = temp_dir();
2096 let dir3 = temp_dir();
2097 let dir4 = temp_dir();
2098
2099 write_cfg(
2100 &dir1,
2101 &format!(
2102 "content=Root.esm\nconfig={}\nconfig={}\n",
2103 dir2.display(),
2104 dir3.display()
2105 ),
2106 );
2107 write_cfg(
2108 &dir2,
2109 &format!("content=Branch.esm\nconfig={}\n", dir4.display()),
2110 );
2111 write_cfg(&dir3, "content=Sibling.esm\n");
2112 write_cfg(&dir4, "content=Leaf.esm\n");
2113
2114 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
2115 let actual: Vec<String> = config
2116 .content_files_iter()
2117 .map(|setting| setting.value().clone())
2118 .collect();
2119
2120 assert_eq!(
2121 actual,
2122 vec!["Root.esm", "Branch.esm", "Sibling.esm", "Leaf.esm"],
2123 "content= should follow the same chain priority order as documented for config= traversal"
2124 );
2125 }
2126
2127 #[test]
2128 fn test_config_chain_priority_order_for_groundcover_lists_matches_openmw_docs_example() {
2129 let dir1 = temp_dir();
2130 let dir2 = temp_dir();
2131 let dir3 = temp_dir();
2132 let dir4 = temp_dir();
2133
2134 write_cfg(
2135 &dir1,
2136 &format!(
2137 "groundcover=Root.esp\nconfig={}\nconfig={}\n",
2138 dir2.display(),
2139 dir3.display()
2140 ),
2141 );
2142 write_cfg(
2143 &dir2,
2144 &format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
2145 );
2146 write_cfg(&dir3, "groundcover=Sibling.esp\n");
2147 write_cfg(&dir4, "groundcover=Leaf.esp\n");
2148
2149 let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
2150 let actual: Vec<String> = config
2151 .groundcover_iter()
2152 .map(|setting| setting.value().clone())
2153 .collect();
2154
2155 assert_eq!(
2156 actual,
2157 vec!["Root.esp", "Branch.esp", "Sibling.esp", "Leaf.esp"],
2158 "groundcover= should follow the same chain priority order as documented for config= traversal"
2159 );
2160 }
2161
2162 #[test]
2163 fn test_config_chain_priority_order_matches_openmw_docs_example() {
2164 let dir1 = temp_dir();
2165 let dir2 = temp_dir();
2166 let dir3 = temp_dir();
2167 let dir4 = temp_dir();
2168
2169 write_cfg(
2170 &dir1,
2171 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
2172 );
2173 write_cfg(
2174 &dir2,
2175 &format!("encoding=win1250\nconfig={}\n", dir4.display()),
2176 );
2177 write_cfg(&dir3, "encoding=win1251\n");
2178 write_cfg(&dir4, "encoding=win1252\n");
2179
2180 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
2181
2182 assert_eq!(
2183 config.encoding().unwrap().to_string().trim(),
2184 "encoding=win1252"
2185 );
2186 assert_eq!(config.user_config_path(), dir4);
2187 }
2188
2189 #[test]
2190 fn test_config_chain_priority_order_with_user_data_crosscheck() {
2191 let dir1 = temp_dir();
2192 let dir2 = temp_dir();
2193 let dir3 = temp_dir();
2194 let dir4 = temp_dir();
2195
2196 write_cfg(
2197 &dir1,
2198 &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
2199 );
2200 write_cfg(
2201 &dir2,
2202 &format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
2203 );
2204 write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
2205 write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
2206
2207 let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
2208
2209 assert_eq!(config.user_config_path(), dir4);
2210 assert_eq!(config.userdata().unwrap().parsed(), dir4.as_path());
2211 }
2212
2213 #[test]
2218 fn test_root_config_file_points_to_cfg() {
2219 let dir = temp_dir();
2220 write_cfg(&dir, "");
2221 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2222 assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2223 }
2224
2225 #[test]
2226 fn test_root_config_dir_is_parent() {
2227 let dir = temp_dir();
2228 write_cfg(&dir, "");
2229 let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2230 assert_eq!(config.root_config_dir(), dir);
2231 }
2232
2233 #[test]
2238 fn test_clone_is_independent() {
2239 let mut original = load("content=Morrowind.esm\n");
2240 let mut cloned = original.clone();
2241 cloned.add_content_file("Tribunal.esm").unwrap();
2242 original.add_content_file("Bloodmoon.esm").unwrap();
2243 assert!(cloned.has_content_file("Tribunal.esm"));
2244 assert!(!cloned.has_content_file("Bloodmoon.esm"));
2245 assert!(original.has_content_file("Bloodmoon.esm"));
2246 assert!(!original.has_content_file("Tribunal.esm"));
2247 }
2248
2249 fn assert_indexes_consistent(config: &OpenMWConfiguration) {
2250 use std::collections::{HashMap, HashSet};
2251
2252 config.ensure_game_setting_indexes();
2253
2254 let scanned_content: HashSet<String> = config
2255 .settings
2256 .iter()
2257 .filter_map(|setting| match setting {
2258 SettingValue::ContentFile(file) => Some(file.value().clone()),
2259 _ => None,
2260 })
2261 .collect();
2262 let scanned_groundcover: HashSet<String> = config
2263 .settings
2264 .iter()
2265 .filter_map(|setting| match setting {
2266 SettingValue::Groundcover(file) => Some(file.value().clone()),
2267 _ => None,
2268 })
2269 .collect();
2270 let scanned_archives: HashSet<String> = config
2271 .settings
2272 .iter()
2273 .filter_map(|setting| match setting {
2274 SettingValue::BethArchive(file) => Some(file.value().clone()),
2275 _ => None,
2276 })
2277 .collect();
2278 let scanned_data_dirs: HashSet<PathBuf> = config
2279 .settings
2280 .iter()
2281 .filter_map(|setting| match setting {
2282 SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
2283 _ => None,
2284 })
2285 .collect();
2286
2287 let mut scanned_game_setting_last = HashMap::new();
2288 for (index, setting) in config.settings.iter().enumerate() {
2289 if let SettingValue::GameSetting(game_setting) = setting {
2290 scanned_game_setting_last.insert(game_setting.key().clone(), index);
2291 }
2292 }
2293
2294 let mut scanned_game_setting_order = Vec::new();
2295 let mut seen = HashSet::new();
2296 for (index, setting) in config.settings.iter().enumerate().rev() {
2297 if let SettingValue::GameSetting(game_setting) = setting
2298 && seen.insert(game_setting.key())
2299 {
2300 scanned_game_setting_order.push(index);
2301 }
2302 }
2303
2304 assert_eq!(config.indexed_content, scanned_content);
2305 assert_eq!(config.indexed_groundcover, scanned_groundcover);
2306 assert_eq!(config.indexed_archives, scanned_archives);
2307 assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
2308 assert_eq!(
2309 *config.indexed_game_setting_last.borrow(),
2310 scanned_game_setting_last
2311 );
2312 assert_eq!(
2313 *config.indexed_game_setting_order.borrow(),
2314 scanned_game_setting_order
2315 );
2316
2317 for file in &config.indexed_content {
2318 assert!(config.has_content_file(file));
2319 }
2320 for file in &config.indexed_groundcover {
2321 assert!(config.has_groundcover_file(file));
2322 }
2323 for file in &config.indexed_archives {
2324 assert!(config.has_archive_file(file));
2325 }
2326 for dir in &config.indexed_data_dirs {
2327 assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
2328 }
2329
2330 let iter_keys: Vec<String> = config
2331 .game_settings()
2332 .map(|setting| setting.key().clone())
2333 .collect();
2334 let expected_keys: Vec<String> = config
2335 .indexed_game_setting_order
2336 .borrow()
2337 .iter()
2338 .filter_map(|index| match &config.settings[*index] {
2339 SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
2340 _ => None,
2341 })
2342 .collect();
2343 assert_eq!(iter_keys, expected_keys);
2344
2345 for (key, index) in config.indexed_game_setting_last.borrow().iter() {
2346 let expected_value = match &config.settings[*index] {
2347 SettingValue::GameSetting(game_setting) => game_setting.value(),
2348 _ => unreachable!("game setting index points to non-game setting"),
2349 };
2350 assert_eq!(
2351 config.get_game_setting(key).map(GameSettingType::value),
2352 Some(expected_value)
2353 );
2354 }
2355 }
2356
2357 #[test]
2358 fn test_indexes_remain_coherent_through_mutations() {
2359 let mut config = load(
2360 "content=Morrowind.esm\n\
2361content=Tribunal.esm\n\
2362groundcover=Grass.esp\n\
2363data=/tmp/data\n\
2364fallback-archive=Morrowind.bsa\n\
2365fallback=iGamma,1.00\n",
2366 );
2367 assert_indexes_consistent(&config);
2368
2369 config.add_content_file("Bloodmoon.esm").unwrap();
2370 assert_indexes_consistent(&config);
2371
2372 config.remove_content_file("Tribunal.esm");
2373 assert_indexes_consistent(&config);
2374
2375 config.add_groundcover_file("Flora.esp").unwrap();
2376 assert_indexes_consistent(&config);
2377
2378 config.remove_groundcover_file("Grass.esp");
2379 assert_indexes_consistent(&config);
2380
2381 config.add_archive_file("Tribunal.bsa").unwrap();
2382 assert_indexes_consistent(&config);
2383
2384 config.remove_archive_file("Morrowind.bsa");
2385 assert_indexes_consistent(&config);
2386
2387 config.add_data_directory(Path::new("/tmp/extra-data"));
2388 assert_indexes_consistent(&config);
2389
2390 config.remove_data_directory(&PathBuf::from("/tmp/data"));
2391 assert_indexes_consistent(&config);
2392
2393 config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
2394 assert_indexes_consistent(&config);
2395
2396 config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
2397 assert_indexes_consistent(&config);
2398
2399 config
2400 .set_game_settings(Some(vec![
2401 "iFoo,10".to_string(),
2402 "iFoo,11".to_string(),
2403 "fBar,1.5".to_string(),
2404 ]))
2405 .unwrap();
2406 assert_indexes_consistent(&config);
2407
2408 let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
2409 assert!(err.is_err());
2410 assert_indexes_consistent(&config);
2411
2412 config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
2413 assert_indexes_consistent(&config);
2414 }
2415
2416 #[test]
2417 fn test_indexes_coherent_after_replace_during_load() {
2418 let config = load(
2419 "content=Root.esm\n\
2420replace=content\n\
2421content=AfterReplace.esm\n\
2422groundcover=GrassRoot.esp\n\
2423replace=groundcover\n\
2424groundcover=GrassAfter.esp\n\
2425fallback-archive=Root.bsa\n\
2426replace=fallback-archives\n\
2427fallback-archive=After.bsa\n\
2428fallback=iFoo,1\n\
2429replace=fallback\n\
2430fallback=iFoo,2\n",
2431 );
2432
2433 assert_indexes_consistent(&config);
2434 assert!(config.has_content_file("AfterReplace.esm"));
2435 assert!(!config.has_content_file("Root.esm"));
2436 assert!(config.has_groundcover_file("GrassAfter.esp"));
2437 assert!(!config.has_groundcover_file("GrassRoot.esp"));
2438 assert!(config.has_archive_file("After.bsa"));
2439 assert!(!config.has_archive_file("Root.bsa"));
2440 assert_eq!(
2441 config.get_game_setting("iFoo").map(GameSettingType::value),
2442 Some("2".into())
2443 );
2444 }
2445}