Skip to main content

openmw_config/
config.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4use 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/// A single parsed entry from an `openmw.cfg` file.
37///
38/// Every line in the file is represented as one of these variants. The variant
39/// determines both the key that appears in the file and how the value is interpreted.
40/// Unknown keys are preserved as [`SettingValue::Generic`] so that round-trip
41/// serialisation never silently drops unrecognised entries.
42#[derive(Clone, Debug)]
43#[non_exhaustive]
44pub enum SettingValue {
45    /// A `data=` entry specifying a VFS data directory.
46    DataDirectory(DirectorySetting),
47    /// A `fallback=` entry containing a Morrowind.ini-style key/value pair.
48    GameSetting(GameSettingType),
49    /// A `user-data=` entry (singleton) specifying the user data root.
50    UserData(DirectorySetting),
51    /// A `data-local=` entry (singleton) specifying the highest-priority data directory.
52    DataLocal(DirectorySetting),
53    /// A `resources=` entry (singleton) specifying the engine resources directory.
54    Resources(DirectorySetting),
55    /// An `encoding=` entry (singleton) specifying the text encoding (`win1250`/`win1251`/`win1252`).
56    Encoding(EncodingSetting),
57    /// A `config=` entry referencing another `openmw.cfg` directory in the chain.
58    SubConfiguration(DirectorySetting),
59    /// Any unrecognised `key=value` line, preserved verbatim.
60    Generic(GenericSetting),
61    /// A `content=` entry naming an ESP/ESM plugin file.
62    ContentFile(FileSetting),
63    /// A `fallback-archive=` entry naming a BSA archive file.
64    BethArchive(FileSetting),
65    /// A `groundcover=` entry naming a groundcover plugin file.
66    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/// A fully-resolved `OpenMW` configuration chain.
162///
163/// Constructed by walking the `config=` chain starting from a root `openmw.cfg`, accumulating
164/// every setting from every file into a flat list.  The list preserves source attribution and
165/// comments so that [`save_user`](Self::save_user) can write back only the user-owned entries,
166/// and [`Display`](std::fmt::Display) can reproduce a valid, comment-preserving `openmw.cfg`.
167#[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    /// # Errors
273    /// Returns [`ConfigError`] if the path from the environment variable is invalid or if config loading fails.
274    ///
275    /// # Example
276    /// ```no_run
277    /// use openmw_config::OpenMWConfiguration;
278    /// let config = OpenMWConfiguration::from_env()?;
279    /// # Ok::<(), openmw_config::ConfigError>(())
280    /// ```
281    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    /// # Errors
313    /// Returns [`ConfigError`] if the path does not exist, is not a valid config, or if loading the config chain fails.
314    ///
315    /// # Example
316    /// ```no_run
317    /// use std::path::PathBuf;
318    /// use openmw_config::OpenMWConfiguration;
319    ///
320    /// // Platform default
321    /// let config = OpenMWConfiguration::new(None)?;
322    ///
323    /// // Specific directory or file path — both are accepted
324    /// let config = OpenMWConfiguration::new(Some(PathBuf::from("/home/user/.config/openmw")))?;
325    /// # Ok::<(), openmw_config::ConfigError>(())
326    /// ```
327    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    /// Path to the configuration file which is the root of the configuration chain
378    /// Typically, this will be whatever is defined in the `Paths` documentation for the appropriate platform:
379    /// <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-files-and-log-files>
380    #[must_use]
381    pub fn root_config_file(&self) -> &std::path::Path {
382        &self.root_config
383    }
384
385    /// Same as `root_config_file`, but returns the directory it's in.
386    /// Useful for reading other configuration files, or if assuming openmw.cfg
387    /// Is always *called* openmw.cfg (which it should be)
388    ///
389    /// # Panics
390    /// Panics if the root config path has no parent directory (i.e. it is a filesystem root).
391    #[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    /// # Errors
405    /// Returns [`ConfigError`] if the user config path cannot be loaded.
406    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    /// # Errors
416    /// Returns [`ConfigError`] if the user config path cannot be loaded.
417    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    /// In order of priority, the list of all openmw.cfg files which were loaded by the configuration chain after the root.
427    /// If the root openmw.cfg is different than the user one, this list will contain the user openmw.cfg as its last element.
428    /// If the root and user openmw.cfg are the *same*, then this list will be empty and the root config should be considered the user config.
429    /// Otherwise, if one wishes to get the contents of the user configuration specifically, construct a new `OpenMWConfiguration` from the last `sub_config`.
430    ///
431    /// Openmw.cfg files are added in declaration order, traversing the `config=` chain level-by-level.
432    /// In a branching chain, sibling `config=` entries are processed before grandchildren.
433    /// If `replace=config` appears in a file, any earlier settings and `config=` entries from that
434    /// same parse scope are discarded before continuing, matching `OpenMW`'s reset semantics.
435    /// The highest-priority openmw.cfg loaded (the last one!) is considered the user openmw.cfg,
436    /// and will be the one which is modifiable by OpenMW-Launcher and `OpenMW` proper.
437    ///
438    /// See <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-sources> for examples and further explanation of multiple config sources.
439    ///
440    /// Path to the highest-level configuration *directory*
441    #[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    /// Content files are the actual *mods* or plugins which are created by either `OpenCS` or Bethesda's construction set
473    /// These entries only refer to the names and ordering of content files.
474    /// vfstool-lib should be used to derive paths
475    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    /// Returns `true` if the named plugin is present in the `content=` list.
483    #[must_use]
484    pub fn has_content_file(&self, file_name: &str) -> bool {
485        self.indexed_content.contains(file_name)
486    }
487
488    /// Returns `true` if the named plugin is present in the `groundcover=` list.
489    #[must_use]
490    pub fn has_groundcover_file(&self, file_name: &str) -> bool {
491        self.indexed_groundcover.contains(file_name)
492    }
493
494    /// Returns `true` if the named archive is present in the `fallback-archive=` list.
495    #[must_use]
496    pub fn has_archive_file(&self, file_name: &str) -> bool {
497        self.indexed_archives.contains(file_name)
498    }
499
500    /// Returns `true` if the given path is present in the `data=` list.
501    ///
502    /// Both `/` and `\` are normalised to the platform separator before comparison,
503    /// so the query does not need to use a specific separator style.
504    #[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    /// # Errors
515    /// Returns [`ConfigError::CannotAddContentFile`] if the file is already present in the config.
516    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    /// Iterates all `groundcover=` entries in definition order.
548    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    /// # Errors
556    /// Returns [`ConfigError::CannotAddGroundcoverFile`] if the file is already present in the config.
557    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    /// Removes all `content=` entries matching `file_name`.
589    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    /// Removes all `groundcover=` entries matching `file_name`.
598    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    /// Removes all `fallback-archive=` entries matching `file_name`.
607    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    /// Removes any `data=` entry whose resolved path or original string matches `data_dir`.
616    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    /// Appends a data directory entry attributed to the user config. Does not check for duplicates.
628    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    /// # Errors
639    /// Returns [`ConfigError::CannotAddArchiveFile`] if the archive is already present in the config.
640    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    /// Iterates all `fallback-archive=` entries in definition order.
672    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    /// Replaces all `content=` entries with `plugins`, or clears them if `None`.
680    ///
681    /// Entries are attributed to the user config path. No duplicate checking is performed.
682    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    /// Replaces all `fallback-archive=` entries with `archives`, or clears them if `None`.
700    ///
701    /// Entries are attributed to the user config path. No duplicate checking is performed.
702    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    /// Iterates all settings for which `predicate` returns `true`.
720    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    /// Removes all settings for which `predicate` returns `true`.
731    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    /// Removes all settings for which `predicate` returns `true`.
739    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    /// Replaces all `data=` entries with `dirs`, or clears them if `None`.
748    ///
749    /// Entries are attributed to the user config path. No duplicate checking is performed.
750    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    /// Given a string resembling a fallback= entry's value, as it would exist in openmw.cfg,
771    /// Add it to the settings map.
772    /// This process must be non-destructive
773    ///
774    /// # Errors
775    /// Returns [`ConfigError`] if `base_value` cannot be parsed as a valid game setting.
776    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    /// Replaces all `fallback=` entries with `settings`, or clears them if `None`.
795    ///
796    /// Each string must be in `Key,Value` format — the same as it would appear after the `=` in
797    /// an `openmw.cfg` `fallback=` line.
798    ///
799    /// # Errors
800    /// Returns [`ConfigError`] if any entry in `settings` cannot be parsed as a valid game setting.
801    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    /// Iterates all `config=` sub-configuration entries in effective definition order.
828    ///
829    /// `replace=config` clears prior `config=` entries in the current parse scope, so this iterator
830    /// only exposes sub-configurations that remain in the effective chain.
831    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    /// Returns the observed configuration-chain traversal in parser order.
839    ///
840    /// Includes successfully loaded config files and `config=` targets that were skipped
841    /// because no `openmw.cfg` exists in that directory.
842    pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
843        self.chain.iter()
844    }
845
846    /// Fallback entries are k/v pairs baked into the value side of k/v pairs in `fallback=` entries of openmw.cfg.
847    /// They are used to express settings which are defined in Morrowind.ini for things such as:
848    /// weather, lighting behaviors, UI colors, and levelup messages.
849    ///
850    /// Returns each key exactly once — when a key appears multiple times in the config chain, the
851    /// last-defined value wins.
852    ///
853    /// # Example
854    /// ```no_run
855    /// use openmw_config::OpenMWConfiguration;
856    /// let config = OpenMWConfiguration::new(None)?;
857    /// for setting in config.game_settings() {
858    ///     println!("{}={}", setting.key(), setting.value());
859    /// }
860    /// # Ok::<(), openmw_config::ConfigError>(())
861    /// ```
862    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    /// Retrieves a gamesetting according to its name.
874    /// This would be whatever text comes after the equals sign `=` and before the first comma `,`
875    /// Case-sensitive!
876    #[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    /// Data directories are the bulk of an `OpenMW` Configuration's contents,
889    /// Composing the list of files from which a VFS is constructed.
890    /// For a VFS implementation, see: <https://github.com/magicaldave/vfstool/tree/main/vfstool_lib>
891    ///
892    /// Calling this function will give the post-parsed versions of directories defined by an openmw.cfg,
893    /// So the real ones may easily be iterated and loaded.
894    /// There is not actually validation anywhere in the crate that `DirectorySettings` refer to a directory which actually exists.
895    /// This is according to the openmw.cfg specification and doesn't technically break anything but should be considered when using these paths.
896    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    /// Saves the currently-defined user openmw.cfg configuration.
1226    ///
1227    /// Only settings whose source is the user config file are written; settings inherited from
1228    /// parent configs are not affected. Modifications applied to inherited settings at runtime
1229    /// are therefore not persisted by this method.
1230    ///
1231    /// # Errors
1232    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
1233    /// Returns [`ConfigError::Io`] if writing the file fails.
1234    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    /// Saves the openmw.cfg belonging to a loaded sub-configuration.
1256    ///
1257    /// `target_dir` must be the directory of a `config=` entry already present in the loaded
1258    /// chain. This method refuses to write to arbitrary paths to prevent accidental overwrites.
1259    ///
1260    /// # Errors
1261    /// Returns [`ConfigError::SubconfigNotLoaded`] if `target_dir` is not part of the chain.
1262    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
1263    /// Returns [`ConfigError::Io`] if writing the file fails.
1264    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
1297/// Keep in mind this is *not* meant to be used as a mechanism to write the openmw.cfg contents.
1298/// Since the openmw.cfg is a merged entity, it is impossible to distinguish the origin of one particular data directory
1299/// Or content file once it has been applied - this is doubly true for entries which may only exist once in openmw.cfg.
1300/// Thus, what this method provides is the composite configuration.
1301///
1302/// It may be safely used to write an openmw.cfg as all directories will be absolutized upon loading the config.
1303///
1304/// Token information is also lost when a config file is processed.
1305/// It is not necessarily recommended to write a configuration file which loads other ones or uses tokens for this reason.
1306///
1307/// Comments are also preserved.
1308impl 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    // -----------------------------------------------------------------------
1334    // Helpers
1335    // -----------------------------------------------------------------------
1336
1337    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        // Use a per-process atomic counter so concurrent tests always get distinct
1346        // directories.  The old `subsec_nanos()` approach could collide when two
1347        // tests ran at the same nanosecond offset in different seconds, causing
1348        // one to overwrite the other's openmw.cfg before it was read.
1349        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    // -----------------------------------------------------------------------
1375    // Content files
1376    // -----------------------------------------------------------------------
1377
1378    #[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    // -----------------------------------------------------------------------
1480    // Fallback archives
1481    // -----------------------------------------------------------------------
1482
1483    #[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    // -----------------------------------------------------------------------
1530    // Groundcover
1531    // -----------------------------------------------------------------------
1532
1533    #[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    // -----------------------------------------------------------------------
1567    // Data directories
1568    // -----------------------------------------------------------------------
1569
1570    #[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    // -----------------------------------------------------------------------
1605    // Fallback (game) settings
1606    // -----------------------------------------------------------------------
1607
1608    #[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        // When the same fallback key appears more than once, game_settings() must emit only the
1625        // last-defined value (last-wins), matching the behavior of get_game_setting().
1626        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    // -----------------------------------------------------------------------
1672    // Encoding
1673    // -----------------------------------------------------------------------
1674
1675    #[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    // -----------------------------------------------------------------------
1702    // Replace semantics
1703    // -----------------------------------------------------------------------
1704
1705    #[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    // -----------------------------------------------------------------------
1729    // Display / serialisation
1730    // -----------------------------------------------------------------------
1731
1732    #[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    // -----------------------------------------------------------------------
1758    // Generic settings
1759    // -----------------------------------------------------------------------
1760
1761    #[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    // -----------------------------------------------------------------------
1769    // save_user
1770    // -----------------------------------------------------------------------
1771
1772    #[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        // Only meaningful on Unix — skip on other platforms
1792        #[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            // Make the directory read-only so we can't write openmw.cfg
1800            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            // Restore permissions before asserting so temp cleanup works
1805            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    // -----------------------------------------------------------------------
1815    // save_subconfig
1816    // -----------------------------------------------------------------------
1817
1818    #[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    // -----------------------------------------------------------------------
1855    // from_env
1856    // -----------------------------------------------------------------------
1857
1858    #[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        // SAFETY: tests that mutate env must not run concurrently with each other.
1865        // The test binary is single-threaded by default so this is acceptable.
1866        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    // -----------------------------------------------------------------------
1887    // ConfigError variants
1888    // -----------------------------------------------------------------------
1889
1890    #[test]
1891    fn test_error_duplicate_archive_file() {
1892        // The parser itself rejects duplicate fallback-archive= entries
1893        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        // A line with no `=` separator should produce InvalidLine
1935        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        // Build a self-referencing config chain that will hit the depth limit
1949        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    // -----------------------------------------------------------------------
1989    // settings_matching and clear_matching
1990    // -----------------------------------------------------------------------
1991
1992    #[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    // -----------------------------------------------------------------------
2009    // sub_configs and config chaining
2010    // -----------------------------------------------------------------------
2011
2012    #[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    // -----------------------------------------------------------------------
2214    // root_config_file / root_config_dir
2215    // -----------------------------------------------------------------------
2216
2217    #[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    // -----------------------------------------------------------------------
2234    // Clone
2235    // -----------------------------------------------------------------------
2236
2237    #[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}