Skip to main content

openmw_config/
config.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
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    fn to_resolved_string(&self) -> Option<String> {
149        match self {
150            SettingValue::UserData(userdata_setting) => Some(format!(
151                "{}user-data={}\n",
152                userdata_setting.meta().comment,
153                userdata_setting.parsed().display()
154            )),
155            SettingValue::DataLocal(data_local_setting) => Some(format!(
156                "{}data-local={}\n",
157                data_local_setting.meta().comment,
158                data_local_setting.parsed().display()
159            )),
160            SettingValue::Resources(resources_setting) => Some(format!(
161                "{}resources={}\n",
162                resources_setting.meta().comment,
163                resources_setting.parsed().display()
164            )),
165            SettingValue::DataDirectory(data_directory) => Some(format!(
166                "{}data={}\n",
167                data_directory.meta().comment,
168                data_directory.parsed().display()
169            )),
170            SettingValue::SubConfiguration(_) => None,
171            SettingValue::Generic(generic) if generic.key().eq_ignore_ascii_case("replace") => None,
172            _ => Some(self.to_string()),
173        }
174    }
175}
176
177macro_rules! insert_dir_setting {
178    ($self:ident, $variant:ident, $value:expr, $config_file:expr, $comment:expr) => {{
179        $self
180            .settings
181            .push(SettingValue::$variant(DirectorySetting::new(
182                $value,
183                $config_file,
184                $comment,
185            )));
186    }};
187}
188
189/// A fully-resolved `OpenMW` configuration chain.
190///
191/// Constructed by walking the `config=` chain starting from a root `openmw.cfg`, accumulating
192/// every setting from every file into a flat list.  The list preserves source attribution and
193/// comments so that [`save_user`](Self::save_user) can write back only the user-owned entries,
194/// and [`Display`](std::fmt::Display) can reproduce a valid, comment-preserving `openmw.cfg`.
195#[derive(Debug, Default, Clone)]
196pub struct OpenMWConfiguration {
197    root_config: PathBuf,
198    settings: Vec<SettingValue>,
199    chain: Vec<ConfigChainEntry>,
200    indexed_content: HashSet<String>,
201    indexed_groundcover: HashSet<String>,
202    indexed_archives: HashSet<String>,
203    indexed_data_dirs: HashSet<PathBuf>,
204    indexed_game_setting_last: RefCell<HashMap<String, usize>>,
205    indexed_game_setting_order: RefCell<Vec<usize>>,
206    game_setting_indexes_dirty: Cell<bool>,
207}
208
209#[derive(Debug, Clone, Eq, PartialEq)]
210pub enum ConfigChainStatus {
211    Loaded,
212    SkippedMissing,
213}
214
215#[derive(Debug, Clone, Eq, PartialEq)]
216pub struct ConfigChainEntry {
217    path: PathBuf,
218    depth: usize,
219    status: ConfigChainStatus,
220}
221
222impl ConfigChainEntry {
223    #[must_use]
224    pub fn path(&self) -> &Path {
225        &self.path
226    }
227
228    #[must_use]
229    pub fn depth(&self) -> usize {
230        self.depth
231    }
232
233    #[must_use]
234    pub fn status(&self) -> &ConfigChainStatus {
235        &self.status
236    }
237}
238
239impl OpenMWConfiguration {
240    fn rebuild_indexes(&mut self) {
241        self.indexed_content.clear();
242        self.indexed_groundcover.clear();
243        self.indexed_archives.clear();
244        self.indexed_data_dirs.clear();
245
246        for setting in &self.settings {
247            match setting {
248                SettingValue::ContentFile(file) => {
249                    self.indexed_content.insert(file.value().clone());
250                }
251                SettingValue::Groundcover(file) => {
252                    self.indexed_groundcover.insert(file.value().clone());
253                }
254                SettingValue::BethArchive(file) => {
255                    self.indexed_archives.insert(file.value().clone());
256                }
257                SettingValue::DataDirectory(dir) => {
258                    self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
259                }
260                _ => {}
261            }
262        }
263
264        self.mark_game_setting_indexes_dirty();
265    }
266
267    fn mark_game_setting_indexes_dirty(&self) {
268        self.game_setting_indexes_dirty.set(true);
269        self.indexed_game_setting_last.borrow_mut().clear();
270        self.indexed_game_setting_order.borrow_mut().clear();
271    }
272
273    fn ensure_game_setting_indexes(&self) {
274        if !self.game_setting_indexes_dirty.get() {
275            return;
276        }
277
278        let mut last = HashMap::new();
279        for (index, setting) in self.settings.iter().enumerate() {
280            if let SettingValue::GameSetting(game_setting) = setting {
281                last.insert(game_setting.key().clone(), index);
282            }
283        }
284
285        let mut seen = HashSet::new();
286        let mut order = Vec::new();
287        for (index, setting) in self.settings.iter().enumerate().rev() {
288            if let SettingValue::GameSetting(game_setting) = setting
289                && seen.insert(game_setting.key())
290            {
291                order.push(index);
292            }
293        }
294
295        *self.indexed_game_setting_last.borrow_mut() = last;
296        *self.indexed_game_setting_order.borrow_mut() = order;
297        self.game_setting_indexes_dirty.set(false);
298    }
299
300    /// # Errors
301    /// Returns [`ConfigError`] if the path from the environment variable is invalid or if config loading fails.
302    ///
303    /// Discovery order matches `OpenMW` root config startup semantics:
304    ///
305    /// 1. `OPENMW_CONFIG`, an explicit `openmw.cfg` file path.
306    /// 2. `OPENMW_CONFIG_DIR`, a path list of directories containing `openmw.cfg`.
307    /// 3. Executable-adjacent `openmw.cfg`.
308    /// 4. Platform global config `openmw.cfg`.
309    ///
310    /// This deliberately does not fall back to `Self::new(None)`. User config is loaded only if
311    /// the discovered root config references it, usually through `config="?userconfig?"`.
312    ///
313    /// # Example
314    /// ```no_run
315    /// use openmw_config::OpenMWConfiguration;
316    /// let config = OpenMWConfiguration::from_env()?;
317    /// # Ok::<(), openmw_config::ConfigError>(())
318    /// ```
319    pub fn from_env() -> Result<Self, ConfigError> {
320        if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
321            let explicit_path = util::expand_leading_tilde(&explicit_path);
322
323            if explicit_path.as_os_str().is_empty() {
324                return Err(ConfigError::NotFileOrDirectory(explicit_path));
325            } else if explicit_path.is_absolute() {
326                return Self::new(Some(explicit_path));
327            } else if explicit_path.is_relative() {
328                return Self::new(Some(util::display_preserving_absolute(&explicit_path)?));
329            }
330            return Err(ConfigError::NotFileOrDirectory(explicit_path));
331        } else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
332            let path_list = if cfg!(windows) {
333                path_list.split(';')
334            } else {
335                path_list.split(':')
336            };
337
338            for dir in path_list {
339                let dir = util::expand_leading_tilde(dir);
340                let config_file = dir.join("openmw.cfg");
341
342                if config_file.is_file() {
343                    return Self::new(Some(dir));
344                }
345            }
346        }
347
348        let root_config = crate::try_default_root_config_path()?;
349        Self::new(Some(root_config))
350    }
351
352    /// Loads an `OpenMW` configuration using environment/root discovery with a user-config fallback.
353    ///
354    /// This is the discovery mode external tools normally want:
355    ///
356    /// 1. Honor [`Self::from_env`] semantics, including `OPENMW_CONFIG`, `OPENMW_CONFIG_DIR`,
357    ///    executable-adjacent root config, and platform global root config.
358    /// 2. If strict root discovery finds no root config, fall back to the default user
359    ///    `openmw.cfg` (`?userconfig?/openmw.cfg`).
360    ///
361    /// Explicit environment errors are still errors. This method only adds a fallback for the
362    /// "no local/global root config exists" case.
363    ///
364    /// # Errors
365    /// Returns [`ConfigError`] if config loading fails or if no root/user config exists.
366    pub fn from_env_or_user_config() -> Result<Self, ConfigError> {
367        match Self::from_env() {
368            Ok(config) => Ok(config),
369            Err(ConfigError::CannotFindRootConfig { local, global }) => {
370                let user = crate::try_default_user_config_file()?;
371
372                if user.is_file() {
373                    Self::new(Some(user))
374                } else {
375                    Err(ConfigError::CannotFindAnyConfig {
376                        local,
377                        global,
378                        user,
379                    })
380                }
381            }
382            Err(error) => Err(error),
383        }
384    }
385
386    /// # Errors
387    /// Returns [`ConfigError`] if the path does not exist, is not a valid config, or if loading the config chain fails.
388    ///
389    /// # Example
390    /// ```no_run
391    /// use std::path::PathBuf;
392    /// use openmw_config::OpenMWConfiguration;
393    ///
394    /// // Platform/user config default (?userconfig?/openmw.cfg), not OpenMW root discovery.
395    /// let config = OpenMWConfiguration::new(None)?;
396    ///
397    /// // Specific directory or file path — both are accepted
398    /// let config = OpenMWConfiguration::new(Some(PathBuf::from("/home/user/.config/openmw")))?;
399    /// # Ok::<(), openmw_config::ConfigError>(())
400    /// ```
401    pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
402        let mut config = OpenMWConfiguration::default();
403        let root_config = match path {
404            Some(path) => util::input_config_path(path)?,
405            None => crate::try_default_config_path()?.join("openmw.cfg"),
406        };
407
408        config.root_config = root_config;
409
410        if let Err(error) = config.load(&config.root_config.clone()) {
411            Err(error)
412        } else {
413            if let Some(dir) = config.data_local() {
414                let path = dir.parsed();
415
416                let path_meta = metadata(path);
417                if path_meta.is_err()
418                    && let Err(error) = create_dir_all(path)
419                {
420                    util::debug_log(&format!(
421                        "WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
422                        path.display()
423                    ));
424                }
425
426                let mut synthetic_data_dir = dir.clone();
427                // data-local is part of the effective VFS data directory list, but it is not a
428                // persisted `data=` line. Give the injected entry no source config so save paths
429                // do not write it back as a real data directory and slowly grow the file. Bonsai,
430                // but for configuration lies.
431                synthetic_data_dir.meta.source_config = PathBuf::new();
432                synthetic_data_dir.meta.comment.clear();
433
434                config
435                    .settings
436                    .push(SettingValue::DataDirectory(synthetic_data_dir));
437            }
438
439            if let Some(setting) = config.resources() {
440                let dir = setting.parsed();
441
442                let engine_vfs = DirectorySetting::new(
443                    dir.join("vfs").to_string_lossy().to_string(),
444                    setting.meta.source_config.clone(),
445                    &mut setting.meta.comment.clone(),
446                );
447
448                config
449                    .settings
450                    .insert(0, SettingValue::DataDirectory(engine_vfs));
451            }
452
453            util::debug_log(&format!("{:#?}", config.settings));
454
455            Ok(config)
456        }
457    }
458
459    /// Creates an empty configuration rooted at `user_config_dir` without reading from disk.
460    ///
461    /// The argument is a configuration directory, not an `openmw.cfg` file path. New settings are
462    /// attributed to `user_config_dir/openmw.cfg`, and save/export APIs treat that file as the user
463    /// config. The directory and file do not need to exist yet.
464    ///
465    /// # Errors
466    /// Returns [`ConfigError::NotFileOrDirectory`] if `user_config_dir` is empty or is shaped like
467    /// an `openmw.cfg` file path. This keeps the directory-level API honest for future config-family
468    /// files such as `settings.cfg` and `shaders.yaml`.
469    pub fn new_empty(user_config_dir: impl Into<PathBuf>) -> Result<Self, ConfigError> {
470        let mut user_config_dir = user_config_dir.into();
471
472        if user_config_dir.as_os_str().is_empty()
473            || user_config_dir
474                .file_name()
475                .is_some_and(|file_name| file_name == "openmw.cfg")
476        {
477            return Err(ConfigError::NotFileOrDirectory(user_config_dir));
478        }
479
480        if user_config_dir.is_relative() {
481            user_config_dir = util::display_preserving_absolute(&user_config_dir)?;
482        }
483
484        Ok(Self {
485            root_config: user_config_dir.join("openmw.cfg"),
486            ..Self::default()
487        })
488    }
489
490    /// Loads a configuration if it exists, otherwise creates an empty configuration at the same
491    /// config context.
492    ///
493    /// Existing paths behave exactly like [`Self::new`]. A missing `openmw.cfg` file path starts an
494    /// empty config from its parent directory; a missing directory path starts an empty config from
495    /// that directory. No input file is created unless a later save call writes one.
496    ///
497    /// # Errors
498    /// Returns [`ConfigError`] for empty paths, invalid existing inputs, or load failures from
499    /// existing configs.
500    pub fn load_optional(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
501        let path = path.into();
502
503        if path.as_os_str().is_empty() {
504            return Err(ConfigError::NotFileOrDirectory(path));
505        }
506
507        if path.is_dir() && !path.join("openmw.cfg").exists() {
508            return Self::new_empty(path);
509        }
510
511        if path.exists() {
512            return Self::new(Some(path));
513        }
514
515        if path
516            .file_name()
517            .is_some_and(|file_name| file_name == "openmw.cfg")
518        {
519            let parent = path
520                .parent()
521                .filter(|parent| !parent.as_os_str().is_empty())
522                .unwrap_or_else(|| Path::new("."));
523            return Self::new_empty(parent);
524        }
525
526        Self::new_empty(path)
527    }
528
529    /// Path to the configuration file which is the root of the configuration chain
530    /// For [`Self::from_env`], this is the root discovered by `OpenMW` startup semantics: explicit
531    /// environment override, executable-adjacent `openmw.cfg`, or platform global config. It is not
532    /// necessarily the user config; package installs usually load the user config through a root
533    /// `config="?userconfig?"` entry.
534    ///
535    /// Typically, this will be whatever is defined in the `Paths` documentation for the appropriate platform:
536    /// <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-files-and-log-files>
537    #[must_use]
538    pub fn root_config_file(&self) -> &std::path::Path {
539        &self.root_config
540    }
541
542    /// Same as `root_config_file`, but returns the directory it's in.
543    /// Useful for reading other configuration files, or if assuming openmw.cfg
544    /// Is always *called* openmw.cfg (which it should be)
545    ///
546    /// # Panics
547    /// Panics if the root config path has no parent directory (i.e. it is a filesystem root).
548    #[must_use]
549    pub fn root_config_dir(&self) -> PathBuf {
550        self.root_config
551            .parent()
552            .expect("root_config has no parent directory")
553            .to_path_buf()
554    }
555
556    #[must_use]
557    pub fn is_user_config(&self) -> bool {
558        util::paths_equivalent(&self.root_config_dir(), &self.user_config_path())
559    }
560
561    /// # Errors
562    /// Returns [`ConfigError`] if the user config path cannot be loaded.
563    pub fn user_config(self) -> Result<Self, ConfigError> {
564        let user_path = self.user_config_path();
565        if util::paths_equivalent(&self.root_config_dir(), &user_path) {
566            Ok(self)
567        } else {
568            Self::new(Some(user_path))
569        }
570    }
571
572    /// # Errors
573    /// Returns [`ConfigError`] if the user config path cannot be loaded.
574    pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
575        let user_path = self.user_config_path();
576        if util::paths_equivalent(&self.root_config_dir(), &user_path) {
577            Ok(self.clone())
578        } else {
579            Self::new(Some(user_path))
580        }
581    }
582
583    /// In order of priority, the list of all openmw.cfg files which were loaded by the configuration chain after the root.
584    /// If the root openmw.cfg is different than the user one, this list will contain the user openmw.cfg as its last element.
585    /// 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.
586    /// Otherwise, if one wishes to get the contents of the user configuration specifically, construct a new `OpenMWConfiguration` from the last `sub_config`.
587    ///
588    /// Openmw.cfg files are added in declaration order, traversing the `config=` chain level-by-level.
589    /// In a branching chain, sibling `config=` entries are processed before grandchildren.
590    /// If `replace=config` appears in a file, any earlier settings and `config=` entries from that
591    /// same parse scope are discarded before continuing, matching `OpenMW`'s reset semantics.
592    /// The highest-priority openmw.cfg loaded (the last one!) is considered the user openmw.cfg,
593    /// and will be the one which is modifiable by OpenMW-Launcher and `OpenMW` proper.
594    ///
595    /// See <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-sources> for examples and further explanation of multiple config sources.
596    ///
597    /// Path to the highest-level configuration *directory*
598    #[must_use]
599    pub fn user_config_path(&self) -> PathBuf {
600        self.sub_configs()
601            .map(|setting| setting.parsed().to_path_buf())
602            .last()
603            .unwrap_or_else(|| self.root_config_dir())
604    }
605
606    impl_singleton_setting! {
607        UserData => {
608            get: userdata,
609            set: set_userdata,
610            in_type: DirectorySetting
611        },
612        Resources => {
613            get: resources,
614            set: set_resources,
615            in_type: DirectorySetting
616        },
617        DataLocal => {
618            get: data_local,
619            set: set_data_local,
620            in_type: DirectorySetting
621        },
622        Encoding => {
623            get: encoding,
624            set: set_encoding,
625            in_type: EncodingSetting
626        }
627    }
628
629    fn directory_setting_for_user_config(&self, path: impl AsRef<Path>) -> DirectorySetting {
630        DirectorySetting::new(
631            path.as_ref().to_string_lossy(),
632            self.user_config_path().join("openmw.cfg"),
633            &mut String::default(),
634        )
635    }
636
637    /// Replaces the singleton `data-local=` directory with a path attributed to the user config.
638    ///
639    /// The value is parsed through [`DirectorySetting`] so quote, token, and relative-path handling
640    /// remains centralized.
641    pub fn set_data_local_path(&mut self, path: impl AsRef<Path>) {
642        let setting = self.directory_setting_for_user_config(path);
643        self.set_data_local(Some(setting));
644    }
645
646    /// Replaces the singleton `resources=` directory with a path attributed to the user config.
647    ///
648    /// The value is parsed through [`DirectorySetting`] so quote, token, and relative-path handling
649    /// remains centralized.
650    pub fn set_resources_path(&mut self, path: impl AsRef<Path>) {
651        let setting = self.directory_setting_for_user_config(path);
652        self.set_resources(Some(setting));
653    }
654
655    /// Replaces the singleton `user-data=` directory with a path attributed to the user config.
656    ///
657    /// The cfg key is `user-data`; `?userdata?` is only a token. There is no `userdata=` key here.
658    pub fn set_user_data_path(&mut self, path: impl AsRef<Path>) {
659        let setting = self.directory_setting_for_user_config(path);
660        self.set_userdata(Some(setting));
661    }
662
663    /// Clears the singleton `data-local=` directory setting.
664    pub fn clear_data_local(&mut self) {
665        self.set_data_local(None);
666    }
667
668    /// Clears the singleton `resources=` directory setting.
669    pub fn clear_resources(&mut self) {
670        self.set_resources(None);
671    }
672
673    /// Clears the singleton `user-data=` directory setting.
674    pub fn clear_user_data(&mut self) {
675        self.set_userdata(None);
676    }
677
678    /// Content files are the actual *mods* or plugins which are created by either `OpenCS` or Bethesda's construction set
679    /// These entries only refer to the names and ordering of content files.
680    /// vfstool-lib should be used to derive paths
681    pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
682        self.settings.iter().filter_map(|setting| match setting {
683            SettingValue::ContentFile(plugin) => Some(plugin),
684            _ => None,
685        })
686    }
687
688    /// Returns `true` if the named plugin is present in the `content=` list.
689    #[must_use]
690    pub fn has_content_file(&self, file_name: &str) -> bool {
691        self.indexed_content.contains(file_name)
692    }
693
694    /// Returns `true` if the named plugin is present in the `groundcover=` list.
695    #[must_use]
696    pub fn has_groundcover_file(&self, file_name: &str) -> bool {
697        self.indexed_groundcover.contains(file_name)
698    }
699
700    /// Returns `true` if the named archive is present in the `fallback-archive=` list.
701    #[must_use]
702    pub fn has_archive_file(&self, file_name: &str) -> bool {
703        self.indexed_archives.contains(file_name)
704    }
705
706    /// Returns `true` if the given path is present in the `data=` list.
707    ///
708    /// Both `/` and `\` are normalised to the platform separator before comparison,
709    /// so the query does not need to use a specific separator style.
710    #[must_use]
711    pub fn has_data_dir(&self, file_name: &str) -> bool {
712        let query = if file_name.contains(['/', '\\']) {
713            PathBuf::from(file_name.replace(['/', '\\'], std::path::MAIN_SEPARATOR_STR))
714        } else {
715            PathBuf::from(file_name)
716        };
717        self.indexed_data_dirs.contains(&query)
718    }
719
720    /// # Errors
721    /// Returns [`ConfigError::CannotAddContentFile`] if the file is already present in the config.
722    pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
723        let duplicate = self.settings.iter().find_map(|setting| match setting {
724            SettingValue::ContentFile(plugin) => {
725                if plugin.value() == content_file {
726                    Some(plugin)
727                } else {
728                    None
729                }
730            }
731            _ => None,
732        });
733
734        if let Some(duplicate) = duplicate {
735            bail_config!(
736                content_already_defined,
737                duplicate.value().to_owned(),
738                duplicate.meta().source_config
739            )
740        }
741
742        self.settings
743            .push(SettingValue::ContentFile(FileSetting::new(
744                content_file,
745                &self.user_config_path().join("openmw.cfg"),
746                &mut String::default(),
747            )));
748        self.rebuild_indexes();
749
750        Ok(())
751    }
752
753    /// Iterates all `groundcover=` entries in definition order.
754    pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
755        self.settings.iter().filter_map(|setting| match setting {
756            SettingValue::Groundcover(grass) => Some(grass),
757            _ => None,
758        })
759    }
760
761    /// # Errors
762    /// Returns [`ConfigError::CannotAddGroundcoverFile`] if the file is already present in the config.
763    pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
764        let duplicate = self.settings.iter().find_map(|setting| match setting {
765            SettingValue::Groundcover(plugin) => {
766                if plugin.value() == content_file {
767                    Some(plugin)
768                } else {
769                    None
770                }
771            }
772            _ => None,
773        });
774
775        if let Some(duplicate) = duplicate {
776            bail_config!(
777                groundcover_already_defined,
778                duplicate.value().to_owned(),
779                duplicate.meta().source_config
780            )
781        }
782
783        self.settings
784            .push(SettingValue::Groundcover(FileSetting::new(
785                content_file,
786                &self.user_config_path().join("openmw.cfg"),
787                &mut String::default(),
788            )));
789        self.rebuild_indexes();
790
791        Ok(())
792    }
793
794    /// Removes all `content=` entries matching `file_name`.
795    pub fn remove_content_file(&mut self, file_name: &str) {
796        self.clear_matching_internal(|setting| match setting {
797            SettingValue::ContentFile(existing_file) => existing_file == file_name,
798            _ => false,
799        });
800        self.rebuild_indexes();
801    }
802
803    /// Removes all `groundcover=` entries matching `file_name`.
804    pub fn remove_groundcover_file(&mut self, file_name: &str) {
805        self.clear_matching_internal(|setting| match setting {
806            SettingValue::Groundcover(existing_file) => existing_file == file_name,
807            _ => false,
808        });
809        self.rebuild_indexes();
810    }
811
812    /// Removes all `fallback-archive=` entries matching `file_name`.
813    pub fn remove_archive_file(&mut self, file_name: &str) {
814        self.clear_matching_internal(|setting| match setting {
815            SettingValue::BethArchive(existing_file) => existing_file == file_name,
816            _ => false,
817        });
818        self.rebuild_indexes();
819    }
820
821    /// Removes any `data=` entry whose resolved path or original string matches `data_dir`.
822    pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
823        self.clear_matching_internal(|setting| match setting {
824            SettingValue::DataDirectory(existing_data_dir) => {
825                existing_data_dir.parsed() == data_dir
826                    || existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
827            }
828            _ => false,
829        });
830        self.rebuild_indexes();
831    }
832
833    /// Appends a data directory entry attributed to the user config. Does not check for duplicates.
834    pub fn add_data_directory(&mut self, dir: &Path) {
835        self.settings
836            .push(SettingValue::DataDirectory(DirectorySetting::new(
837                dir.to_string_lossy(),
838                self.user_config_path().join("openmw.cfg"),
839                &mut String::default(),
840            )));
841        self.rebuild_indexes();
842    }
843
844    /// # Errors
845    /// Returns [`ConfigError::CannotAddArchiveFile`] if the archive is already present in the config.
846    pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
847        let duplicate = self.settings.iter().find_map(|setting| match setting {
848            SettingValue::BethArchive(archive) => {
849                if archive.value() == archive_file {
850                    Some(archive)
851                } else {
852                    None
853                }
854            }
855            _ => None,
856        });
857
858        if let Some(duplicate) = duplicate {
859            bail_config!(
860                duplicate_archive_file,
861                duplicate.value().to_owned(),
862                duplicate.meta().source_config
863            )
864        }
865
866        self.settings
867            .push(SettingValue::BethArchive(FileSetting::new(
868                archive_file,
869                &self.user_config_path().join("openmw.cfg"),
870                &mut String::default(),
871            )));
872        self.rebuild_indexes();
873
874        Ok(())
875    }
876
877    /// Iterates all `fallback-archive=` entries in definition order.
878    pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
879        self.settings.iter().filter_map(|setting| match setting {
880            SettingValue::BethArchive(archive) => Some(archive),
881            _ => None,
882        })
883    }
884
885    /// Replaces all `content=` entries with `plugins`, or clears them if `None`.
886    ///
887    /// Entries are attributed to the user config path. No duplicate checking is performed.
888    pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
889        self.clear_matching_internal(|setting| matches!(setting, SettingValue::ContentFile(_)));
890
891        if let Some(plugins) = plugins {
892            let cfg_path = self.user_config_path().join("openmw.cfg");
893            let mut empty = String::default();
894            for plugin in plugins {
895                self.settings
896                    .push(SettingValue::ContentFile(FileSetting::new(
897                        &plugin, &cfg_path, &mut empty,
898                    )));
899            }
900        }
901
902        self.rebuild_indexes();
903    }
904
905    /// Replaces all `fallback-archive=` entries with `archives`, or clears them if `None`.
906    ///
907    /// Entries are attributed to the user config path. No duplicate checking is performed.
908    pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
909        self.clear_matching_internal(|setting| matches!(setting, SettingValue::BethArchive(_)));
910
911        if let Some(archives) = archives {
912            let cfg_path = self.user_config_path().join("openmw.cfg");
913            let mut empty = String::default();
914            for archive in archives {
915                self.settings
916                    .push(SettingValue::BethArchive(FileSetting::new(
917                        &archive, &cfg_path, &mut empty,
918                    )));
919            }
920        }
921
922        self.rebuild_indexes();
923    }
924
925    /// Iterates all preserved generic `key=value` entries in definition order.
926    pub fn generic_settings_iter(&self) -> impl Iterator<Item = &GenericSetting> {
927        self.settings.iter().filter_map(|setting| match setting {
928            SettingValue::Generic(generic) => Some(generic),
929            _ => None,
930        })
931    }
932
933    /// Replaces all preserved generic `key=value` entries with `values`, or clears them if `None`.
934    ///
935    /// Entries are attributed to the user config path. No duplicate checking is performed.
936    pub fn set_generic_settings(&mut self, key: &str, values: Option<Vec<String>>) {
937        self.clear_matching_internal(|setting| match setting {
938            SettingValue::Generic(generic) => generic.key() == key,
939            _ => false,
940        });
941
942        if let Some(values) = values {
943            let cfg_path = self.user_config_path().join("openmw.cfg");
944            let mut empty = String::default();
945
946            for value in values {
947                self.settings
948                    .push(SettingValue::Generic(GenericSetting::new(
949                        key, &value, &cfg_path, &mut empty,
950                    )));
951            }
952        }
953
954        self.rebuild_indexes();
955    }
956
957    /// Appends a preserved generic `key=value` entry attributed to the user config.
958    pub fn add_generic_setting(&mut self, key: &str, value: &str) {
959        self.settings
960            .push(SettingValue::Generic(GenericSetting::new(
961                key,
962                value,
963                &self.user_config_path().join("openmw.cfg"),
964                &mut String::default(),
965            )));
966        self.rebuild_indexes();
967    }
968
969    /// Iterates all settings for which `predicate` returns `true`.
970    pub fn settings_matching<'a, P>(
971        &'a self,
972        predicate: P,
973    ) -> impl Iterator<Item = &'a SettingValue>
974    where
975        P: Fn(&SettingValue) -> bool + 'a,
976    {
977        self.settings.iter().filter(move |s| predicate(s))
978    }
979
980    /// Removes all settings for which `predicate` returns `true`.
981    fn clear_matching_internal<P>(&mut self, predicate: P)
982    where
983        P: Fn(&SettingValue) -> bool,
984    {
985        self.settings.retain(|s| !predicate(s));
986    }
987
988    fn is_synthetic_data_local_data_directory(&self, setting: &SettingValue) -> bool {
989        let SettingValue::DataDirectory(data_dir) = setting else {
990            return false;
991        };
992
993        if !data_dir.meta().source_config.as_os_str().is_empty() {
994            return false;
995        }
996
997        self.data_local().is_some_and(|data_local| {
998            data_dir.parsed() == data_local.parsed()
999                || data_dir.original_str() == data_local.original_str()
1000        })
1001    }
1002
1003    /// Removes all settings for which `predicate` returns `true`.
1004    pub fn clear_matching<P>(&mut self, predicate: P)
1005    where
1006        P: Fn(&SettingValue) -> bool,
1007    {
1008        self.clear_matching_internal(predicate);
1009        self.rebuild_indexes();
1010    }
1011
1012    /// Replaces all `data=` entries with `dirs`, or clears them if `None`.
1013    ///
1014    /// Entries are attributed to the user config path. No duplicate checking is performed.
1015    pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
1016        self.clear_matching_internal(|setting| matches!(setting, SettingValue::DataDirectory(_)));
1017
1018        if let Some(dirs) = dirs {
1019            let cfg_path = self.user_config_path().join("openmw.cfg");
1020            let mut empty = String::default();
1021
1022            for dir in dirs {
1023                self.settings
1024                    .push(SettingValue::DataDirectory(DirectorySetting::new(
1025                        dir.to_string_lossy(),
1026                        cfg_path.clone(),
1027                        &mut empty,
1028                    )));
1029            }
1030        }
1031
1032        self.rebuild_indexes();
1033    }
1034
1035    /// Given a string resembling a fallback= entry's value, as it would exist in openmw.cfg,
1036    /// Add it to the settings map.
1037    /// This process must be non-destructive
1038    ///
1039    /// # Errors
1040    /// Returns [`ConfigError`] if `base_value` cannot be parsed as a valid game setting.
1041    pub fn set_game_setting(
1042        &mut self,
1043        base_value: &str,
1044        config_path: Option<PathBuf>,
1045        comment: &mut String,
1046    ) -> Result<(), ConfigError> {
1047        let new_setting = GameSettingType::try_from((
1048            base_value.to_owned(),
1049            config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
1050            comment,
1051        ))?;
1052
1053        self.settings.push(SettingValue::GameSetting(new_setting));
1054        self.rebuild_indexes();
1055
1056        Ok(())
1057    }
1058
1059    /// Replaces all `fallback=` entries with `settings`, or clears them if `None`.
1060    ///
1061    /// Each string must be in `Key,Value` format — the same as it would appear after the `=` in
1062    /// an `openmw.cfg` `fallback=` line.
1063    ///
1064    /// # Errors
1065    /// Returns [`ConfigError`] if any entry in `settings` cannot be parsed as a valid game setting.
1066    pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
1067        self.clear_matching_internal(|setting| matches!(setting, SettingValue::GameSetting(_)));
1068
1069        if let Some(settings) = settings {
1070            let cfg_path = self.user_config_path().join("openmw.cfg");
1071            let mut empty = String::default();
1072
1073            for setting in settings {
1074                let parsed =
1075                    match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
1076                        Ok(parsed) => parsed,
1077                        Err(error) => {
1078                            self.rebuild_indexes();
1079                            return Err(error);
1080                        }
1081                    };
1082
1083                self.settings.push(SettingValue::GameSetting(parsed));
1084            }
1085        }
1086
1087        self.rebuild_indexes();
1088
1089        Ok(())
1090    }
1091
1092    /// Iterates all `config=` sub-configuration entries in effective definition order.
1093    ///
1094    /// `replace=config` clears prior `config=` entries in the current parse scope, so this iterator
1095    /// only exposes sub-configurations that remain in the effective chain.
1096    pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
1097        self.settings.iter().filter_map(|setting| match setting {
1098            SettingValue::SubConfiguration(subconfig) => Some(subconfig),
1099            _ => None,
1100        })
1101    }
1102
1103    /// Returns the observed configuration-chain traversal in parser order.
1104    ///
1105    /// Includes successfully loaded config files and `config=` targets that were skipped
1106    /// because no `openmw.cfg` exists in that directory.
1107    pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
1108        self.chain.iter()
1109    }
1110
1111    /// Fallback entries are k/v pairs baked into the value side of k/v pairs in `fallback=` entries of openmw.cfg.
1112    /// They are used to express settings which are defined in Morrowind.ini for things such as:
1113    /// weather, lighting behaviors, UI colors, and levelup messages.
1114    ///
1115    /// Returns each key exactly once — when a key appears multiple times in the config chain, the
1116    /// last-defined value wins.
1117    ///
1118    /// # Example
1119    /// ```no_run
1120    /// use openmw_config::OpenMWConfiguration;
1121    /// let config = OpenMWConfiguration::new(None)?;
1122    /// for setting in config.game_settings() {
1123    ///     println!("{}={}", setting.key(), setting.value());
1124    /// }
1125    /// # Ok::<(), openmw_config::ConfigError>(())
1126    /// ```
1127    pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
1128        self.ensure_game_setting_indexes();
1129        let order = self.indexed_game_setting_order.borrow().clone();
1130        order
1131            .into_iter()
1132            .filter_map(move |index| match &self.settings[index] {
1133                SettingValue::GameSetting(setting) => Some(setting),
1134                _ => None,
1135            })
1136    }
1137
1138    /// Retrieves a gamesetting according to its name.
1139    /// This would be whatever text comes after the equals sign `=` and before the first comma `,`
1140    /// Case-sensitive!
1141    #[must_use]
1142    pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
1143        self.ensure_game_setting_indexes();
1144        self.indexed_game_setting_last
1145            .borrow()
1146            .get(key)
1147            .and_then(|index| match &self.settings[*index] {
1148                SettingValue::GameSetting(setting) => Some(setting),
1149                _ => None,
1150            })
1151    }
1152
1153    /// Data directories are the bulk of an `OpenMW` Configuration's contents,
1154    /// Composing the list of files from which a VFS is constructed.
1155    /// For a VFS implementation, see: <https://github.com/magicaldave/vfstool/tree/main/vfstool_lib>
1156    ///
1157    /// Calling this function will give the post-parsed versions of directories defined by an openmw.cfg,
1158    /// So the real ones may easily be iterated and loaded.
1159    /// There is not actually validation anywhere in the crate that `DirectorySettings` refer to a directory which actually exists.
1160    /// This is according to the openmw.cfg specification and doesn't technically break anything but should be considered when using these paths.
1161    pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
1162        self.settings.iter().filter_map(|setting| match setting {
1163            SettingValue::DataDirectory(data_dir) => Some(data_dir),
1164            _ => None,
1165        })
1166    }
1167
1168    const MAX_CONFIG_DEPTH: usize = 16;
1169
1170    #[allow(clippy::too_many_lines)]
1171    fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
1172        let mut pending_configs = VecDeque::new();
1173        pending_configs.push_back((root_config.to_path_buf(), 0usize));
1174
1175        let mut seen_content: HashSet<String> = self
1176            .settings
1177            .iter()
1178            .filter_map(|setting| match setting {
1179                SettingValue::ContentFile(file) => Some(file.value().clone()),
1180                _ => None,
1181            })
1182            .collect();
1183        let mut seen_groundcover: HashSet<String> = self
1184            .settings
1185            .iter()
1186            .filter_map(|setting| match setting {
1187                SettingValue::Groundcover(file) => Some(file.value().clone()),
1188                _ => None,
1189            })
1190            .collect();
1191        let mut seen_archives: HashSet<String> = self
1192            .settings
1193            .iter()
1194            .filter_map(|setting| match setting {
1195                SettingValue::BethArchive(file) => Some(file.value().clone()),
1196                _ => None,
1197            })
1198            .collect();
1199
1200        while let Some((config_dir, depth)) = pending_configs.pop_front() {
1201            if depth > Self::MAX_CONFIG_DEPTH {
1202                bail_config!(max_depth_exceeded, config_dir);
1203            }
1204
1205            util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", config_dir.display()));
1206
1207            if !config_dir.exists() {
1208                bail_config!(cannot_find, config_dir);
1209            }
1210
1211            let cfg_file_path = if config_dir.is_dir() {
1212                config_dir.join("openmw.cfg")
1213            } else {
1214                config_dir
1215            };
1216
1217            self.chain.push(ConfigChainEntry {
1218                path: cfg_file_path.clone(),
1219                depth,
1220                status: ConfigChainStatus::Loaded,
1221            });
1222
1223            let lines = read_to_string(&cfg_file_path)?;
1224
1225            let mut queued_comment = String::new();
1226            let mut sub_configs: Vec<(String, String)> = Vec::new();
1227
1228            for (index, line) in lines.lines().enumerate() {
1229                let line_no = index + 1;
1230                let trimmed = line.trim();
1231
1232                if trimmed.is_empty() {
1233                    queued_comment.push('\n');
1234                    continue;
1235                } else if trimmed.starts_with('#') {
1236                    queued_comment.push_str(line);
1237                    queued_comment.push('\n');
1238                    continue;
1239                }
1240
1241                let Some((key, value)) = trimmed.split_once('=') else {
1242                    bail_config!(invalid_line, trimmed.into(), cfg_file_path.clone(), line_no);
1243                };
1244
1245                let key = key.trim();
1246                let value = value.trim();
1247
1248                match key {
1249                    "content" => {
1250                        if !seen_content.insert(value.to_owned()) {
1251                            bail_config!(
1252                                duplicate_content_file,
1253                                value.to_owned(),
1254                                cfg_file_path,
1255                                line_no
1256                            );
1257                        }
1258                        self.settings
1259                            .push(SettingValue::ContentFile(FileSetting::new(
1260                                value,
1261                                &cfg_file_path,
1262                                &mut queued_comment,
1263                            )));
1264                    }
1265                    "groundcover" => {
1266                        if !seen_groundcover.insert(value.to_owned()) {
1267                            bail_config!(
1268                                duplicate_groundcover_file,
1269                                value.to_owned(),
1270                                cfg_file_path,
1271                                line_no
1272                            );
1273                        }
1274                        self.settings
1275                            .push(SettingValue::Groundcover(FileSetting::new(
1276                                value,
1277                                &cfg_file_path,
1278                                &mut queued_comment,
1279                            )));
1280                    }
1281                    "fallback-archive" => {
1282                        if !seen_archives.insert(value.to_owned()) {
1283                            bail_config!(
1284                                duplicate_archive_file,
1285                                value.to_owned(),
1286                                cfg_file_path,
1287                                line_no
1288                            );
1289                        }
1290                        self.settings
1291                            .push(SettingValue::BethArchive(FileSetting::new(
1292                                value,
1293                                &cfg_file_path,
1294                                &mut queued_comment,
1295                            )));
1296                    }
1297                    "fallback" => {
1298                        let game_setting = GameSettingType::try_from((
1299                            value.to_owned(),
1300                            cfg_file_path.clone(),
1301                            &mut queued_comment,
1302                        ))
1303                        .map_err(|error| match error {
1304                            ConfigError::InvalidGameSetting {
1305                                value, config_path, ..
1306                            } => ConfigError::InvalidGameSetting {
1307                                value,
1308                                config_path,
1309                                line: Some(line_no),
1310                            },
1311                            _ => error,
1312                        })?;
1313
1314                        self.settings.push(SettingValue::GameSetting(game_setting));
1315                    }
1316                    "encoding" => {
1317                        let encoding = EncodingSetting::try_from((
1318                            value.to_owned(),
1319                            &cfg_file_path,
1320                            &mut queued_comment,
1321                        ))
1322                        .map_err(|error| match error {
1323                            ConfigError::BadEncoding {
1324                                value, config_path, ..
1325                            } => ConfigError::BadEncoding {
1326                                value,
1327                                config_path,
1328                                line: Some(line_no),
1329                            },
1330                            _ => error,
1331                        })?;
1332                        self.set_encoding(Some(encoding));
1333                    }
1334                    "config" => {
1335                        sub_configs.push((value.to_owned(), std::mem::take(&mut queued_comment)));
1336                    }
1337                    "data" => {
1338                        insert_dir_setting!(
1339                            self,
1340                            DataDirectory,
1341                            value,
1342                            cfg_file_path.clone(),
1343                            &mut queued_comment
1344                        );
1345                    }
1346                    "resources" => {
1347                        insert_dir_setting!(
1348                            self,
1349                            Resources,
1350                            value,
1351                            cfg_file_path.clone(),
1352                            &mut queued_comment
1353                        );
1354                    }
1355                    "user-data" => {
1356                        insert_dir_setting!(
1357                            self,
1358                            UserData,
1359                            value,
1360                            cfg_file_path.clone(),
1361                            &mut queued_comment
1362                        );
1363                    }
1364                    "data-local" => {
1365                        insert_dir_setting!(
1366                            self,
1367                            DataLocal,
1368                            value,
1369                            cfg_file_path.clone(),
1370                            &mut queued_comment
1371                        );
1372                    }
1373                    "replace" => match value.to_ascii_lowercase().as_str() {
1374                        "content" => {
1375                            self.clear_matching_internal(|s| {
1376                                matches!(s, SettingValue::ContentFile(_))
1377                            });
1378                            seen_content.clear();
1379                        }
1380                        "data" => {
1381                            self.clear_matching_internal(|s| {
1382                                matches!(s, SettingValue::DataDirectory(_))
1383                            });
1384                        }
1385                        "fallback" => {
1386                            self.clear_matching_internal(|s| {
1387                                matches!(s, SettingValue::GameSetting(_))
1388                            });
1389                        }
1390                        "fallback-archives" => {
1391                            self.clear_matching_internal(|s| {
1392                                matches!(s, SettingValue::BethArchive(_))
1393                            });
1394                            seen_archives.clear();
1395                        }
1396                        "groundcover" => {
1397                            self.clear_matching_internal(|s| {
1398                                matches!(s, SettingValue::Groundcover(_))
1399                            });
1400                            seen_groundcover.clear();
1401                        }
1402                        "data-local" => self.set_data_local(None),
1403                        "resources" => self.set_resources(None),
1404                        "user-data" => self.set_userdata(None),
1405                        "config" => {
1406                            self.settings.clear();
1407                            seen_content.clear();
1408                            seen_groundcover.clear();
1409                            seen_archives.clear();
1410                            sub_configs.clear();
1411                            pending_configs.clear();
1412                        }
1413                        _ => {}
1414                    },
1415                    _ => {
1416                        let setting =
1417                            GenericSetting::new(key, value, &cfg_file_path, &mut queued_comment);
1418                        self.settings.push(SettingValue::Generic(setting));
1419                    }
1420                }
1421            }
1422
1423            for (subconfig_path, mut subconfig_comment) in sub_configs {
1424                let mut comment = std::mem::take(&mut subconfig_comment);
1425                let setting =
1426                    DirectorySetting::new(subconfig_path, cfg_file_path.clone(), &mut comment);
1427                let subconfig_file = setting.parsed().join("openmw.cfg");
1428
1429                if std::fs::metadata(&subconfig_file).is_ok() {
1430                    self.settings.push(SettingValue::SubConfiguration(setting));
1431                    pending_configs.push_back((subconfig_file, depth + 1));
1432                } else {
1433                    self.chain.push(ConfigChainEntry {
1434                        path: subconfig_file,
1435                        depth: depth + 1,
1436                        status: ConfigChainStatus::SkippedMissing,
1437                    });
1438                    util::debug_log_lazy(|| {
1439                        format!(
1440                            "Skipping parsing of {} as this directory does not actually contain an openmw.cfg!",
1441                            setting.parsed().display(),
1442                        )
1443                    });
1444                }
1445            }
1446        }
1447
1448        self.rebuild_indexes();
1449
1450        Ok(())
1451    }
1452
1453    fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1454        use std::io::Write;
1455        use std::time::{SystemTime, UNIX_EPOCH};
1456
1457        let parent = path
1458            .parent()
1459            .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1460
1461        let nonce = SystemTime::now()
1462            .duration_since(UNIX_EPOCH)
1463            .map_or(0, |d| d.as_nanos());
1464        let tmp_path = parent.join(format!(
1465            ".openmw-config-tmp-{}-{}",
1466            std::process::id(),
1467            nonce
1468        ));
1469
1470        let mut file = std::fs::OpenOptions::new()
1471            .write(true)
1472            .create_new(true)
1473            .open(&tmp_path)?;
1474
1475        file.write_all(config_string.as_bytes())?;
1476        file.sync_all()?;
1477
1478        #[cfg(windows)]
1479        {
1480            if path.exists() {
1481                if let Ok(metadata) = std::fs::metadata(path) {
1482                    let mut permissions = metadata.permissions();
1483                    if permissions.readonly() {
1484                        // Windows blocks deletion of read-only files, and the atomic replace path
1485                        // needs the destination gone before we rename the temp file into place.
1486                        permissions.set_readonly(false);
1487                        std::fs::set_permissions(path, permissions)?;
1488                    }
1489                }
1490
1491                std::fs::remove_file(path)?;
1492            }
1493        }
1494
1495        std::fs::rename(&tmp_path, path)?;
1496
1497        Ok(())
1498    }
1499
1500    fn save_string_to_path(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1501        let parent = path
1502            .parent()
1503            .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1504        let writable_probe = parent.join(".openmw-config-write-test");
1505
1506        if !util::is_writable(&writable_probe) {
1507            bail_config!(not_writable, parent);
1508        }
1509
1510        Self::write_config(config_string, path)
1511    }
1512
1513    /// Serializes the fully-composed configuration as a relocated, flattened `openmw.cfg`.
1514    ///
1515    /// Directory-valued settings (`data=`, `data-local=`, `resources=`, and `user-data=`) are
1516    /// emitted from their resolved paths, not their original token or relative spelling. Chain
1517    /// control entries such as `config=` and `replace=` are excluded because this output is already
1518    /// the composed result; reloading the chain from the flattened file would be doing the work
1519    /// twice, and probably differently. Synthetic `data-local`-as-`data` entries are also omitted.
1520    ///
1521    /// Use the [`Display`] implementation for preservation-oriented serialization instead.
1522    #[must_use]
1523    pub fn to_resolved_string(&self) -> String {
1524        use std::fmt::Write as _;
1525
1526        let mut config_string = String::new();
1527
1528        for setting in &self.settings {
1529            if self.is_synthetic_data_local_data_directory(setting) {
1530                continue;
1531            }
1532
1533            if let Some(setting_string) = setting.to_resolved_string() {
1534                config_string.push_str(&setting_string);
1535            }
1536        }
1537
1538        writeln!(
1539            config_string,
1540            "# OpenMW-Config Serializer Version: {}",
1541            env!("CARGO_PKG_VERSION")
1542        )
1543        .expect("writing to a String cannot fail");
1544
1545        config_string
1546    }
1547
1548    /// Writes the full composite configuration to an arbitrary path.
1549    ///
1550    /// This is intended for importer-style output where the exact destination is supplied by the
1551    /// caller rather than inferred from the loaded config chain.
1552    ///
1553    /// # Errors
1554    /// Returns [`ConfigError::NotWritable`] if the destination directory is not writable.
1555    /// Returns [`ConfigError::Io`] if writing the file fails.
1556    pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1557        Self::save_string_to_path(&self.to_string(), path.as_ref())
1558    }
1559
1560    /// Writes [`Self::to_resolved_string`] to an arbitrary path using atomic replace semantics.
1561    ///
1562    /// This is the relocation-safe export API for importers and preview/output tools. It omits
1563    /// `config=` and `replace=` chain-control entries and writes directory-valued settings using
1564    /// resolved paths so moving the output file does not change what those paths mean.
1565    ///
1566    /// # Errors
1567    /// Returns [`ConfigError::NotWritable`] if the destination directory is not writable.
1568    /// Returns [`ConfigError::Io`] if writing the file fails.
1569    pub fn save_resolved_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1570        Self::save_string_to_path(&self.to_resolved_string(), path.as_ref())
1571    }
1572
1573    /// Saves the currently-defined user openmw.cfg configuration.
1574    ///
1575    /// Only settings whose source is the user config file are written; settings inherited from
1576    /// parent configs are not affected. Modifications applied to inherited settings at runtime
1577    /// are therefore not persisted by this method.
1578    ///
1579    /// # Errors
1580    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
1581    /// Returns [`ConfigError::Io`] if writing the file fails.
1582    pub fn save_user(&self) -> Result<(), ConfigError> {
1583        let target_dir = self.user_config_path();
1584        let cfg_path = target_dir.join("openmw.cfg");
1585
1586        std::fs::create_dir_all(&target_dir)?;
1587
1588        if !util::is_writable(&cfg_path) {
1589            bail_config!(not_writable, &cfg_path);
1590        }
1591
1592        let mut user_settings_string = String::new();
1593
1594        for user_setting in self.settings_matching(|setting| {
1595            util::paths_equivalent(&setting.meta().source_config, &cfg_path)
1596        }) {
1597            if self.is_synthetic_data_local_data_directory(user_setting) {
1598                continue;
1599            }
1600
1601            user_settings_string.push_str(&user_setting.to_string());
1602        }
1603
1604        Self::write_config(&user_settings_string, &cfg_path)?;
1605
1606        Ok(())
1607    }
1608
1609    /// Saves the openmw.cfg belonging to a loaded sub-configuration.
1610    ///
1611    /// `target_dir` must be the directory of a `config=` entry already present in the loaded
1612    /// chain. This method refuses to write to arbitrary paths to prevent accidental overwrites.
1613    ///
1614    /// # Errors
1615    /// Returns [`ConfigError::SubconfigNotLoaded`] if `target_dir` is not part of the chain.
1616    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
1617    /// Returns [`ConfigError::Io`] if writing the file fails.
1618    pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
1619        let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
1620            SettingValue::SubConfiguration(subconfig) => {
1621                util::paths_equivalent(subconfig.parsed(), target_dir)
1622                    || subconfig.original() == target_dir.to_string_lossy().as_ref()
1623            }
1624            _ => false,
1625        });
1626
1627        if !subconfig_is_loaded {
1628            bail_config!(subconfig_not_loaded, target_dir);
1629        }
1630
1631        let cfg_path = target_dir.join("openmw.cfg");
1632
1633        if !util::is_writable(&cfg_path) {
1634            bail_config!(not_writable, &cfg_path);
1635        }
1636
1637        let mut subconfig_settings_string = String::new();
1638
1639        for subconfig_setting in self.settings_matching(|setting| {
1640            util::paths_equivalent(&setting.meta().source_config, &cfg_path)
1641        }) {
1642            if self.is_synthetic_data_local_data_directory(subconfig_setting) {
1643                continue;
1644            }
1645
1646            subconfig_settings_string.push_str(&subconfig_setting.to_string());
1647        }
1648
1649        Self::write_config(&subconfig_settings_string, &cfg_path)?;
1650
1651        Ok(())
1652    }
1653}
1654
1655/// Preservation-oriented `openmw.cfg` serialization for the composed configuration.
1656///
1657/// Directory-valued settings use their original text so user-authored relative paths, tokens, and
1658/// quoting survive round-trips. That makes this suitable for preservation-style output and for the
1659/// internals of [`OpenMWConfiguration::save_user`] / [`OpenMWConfiguration::save_subconfig`]. It is
1660/// not relocation-safe flattened export: writing this string to a different directory can change the
1661/// meaning of relative paths. Use [`OpenMWConfiguration::to_resolved_string`] or
1662/// [`OpenMWConfiguration::save_resolved_to_path`] for importer/export output.
1663///
1664/// Synthetic `data-local`-as-`data` entries are omitted. Comments are preserved.
1665impl fmt::Display for OpenMWConfiguration {
1666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1667        self.settings
1668            .iter()
1669            .filter(|setting| !self.is_synthetic_data_local_data_directory(setting))
1670            .try_for_each(|setting| write!(f, "{setting}"))?;
1671
1672        writeln!(
1673            f,
1674            "# OpenMW-Config Serializer Version: {}",
1675            env!("CARGO_PKG_VERSION")
1676        )?;
1677
1678        Ok(())
1679    }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684    use super::*;
1685    use std::io::Write;
1686    use std::sync::atomic::{AtomicU64, Ordering};
1687
1688    // -----------------------------------------------------------------------
1689    // Helpers
1690    // -----------------------------------------------------------------------
1691
1692    fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
1693        let cfg = dir.join("openmw.cfg");
1694        let mut f = std::fs::File::create(&cfg).unwrap();
1695        f.write_all(contents.as_bytes()).unwrap();
1696        cfg
1697    }
1698
1699    fn temp_dir() -> PathBuf {
1700        // Use a per-process atomic counter so concurrent tests always get distinct
1701        // directories.  The old `subsec_nanos()` approach could collide when two
1702        // tests ran at the same nanosecond offset in different seconds, causing
1703        // one to overwrite the other's openmw.cfg before it was read.
1704        static COUNTER: AtomicU64 = AtomicU64::new(0);
1705        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1706        let base =
1707            std::env::temp_dir().join(format!("openmw_cfg_test_{}_{id}", std::process::id()));
1708        std::fs::create_dir_all(&base).unwrap();
1709        base
1710    }
1711
1712    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1713        crate::test_env_lock()
1714    }
1715
1716    unsafe fn clear_from_env_overrides() {
1717        // SAFETY: callers hold env_lock(). Rust 2024 made this explicit because environment
1718        // mutation is process-global. Quite right, annoyingly.
1719        unsafe {
1720            std::env::remove_var("OPENMW_CONFIG");
1721            std::env::remove_var("OPENMW_CONFIG_DIR");
1722            std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
1723            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
1724            std::env::remove_var("OPENMW_FLATPAK_ID");
1725            std::env::remove_var("FLATPAK_ID");
1726        }
1727    }
1728
1729    fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1730        // SAFETY: callers hold env_lock(). Environment mutation is process-global.
1731        unsafe {
1732            if let Some(value) = value {
1733                std::env::set_var(key, value);
1734            } else {
1735                std::env::remove_var(key);
1736            }
1737        }
1738    }
1739
1740    fn load(cfg_contents: &str) -> OpenMWConfiguration {
1741        let dir = temp_dir();
1742        write_cfg(&dir, cfg_contents);
1743        OpenMWConfiguration::new(Some(dir)).unwrap()
1744    }
1745
1746    #[cfg(unix)]
1747    fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
1748        std::os::unix::fs::symlink(target, link).unwrap();
1749    }
1750
1751    // -----------------------------------------------------------------------
1752    // Content files
1753    // -----------------------------------------------------------------------
1754
1755    #[test]
1756    fn test_content_files_empty_on_bare_config() {
1757        let config = load("");
1758        assert!(config.content_files_iter().next().is_none());
1759    }
1760
1761    #[test]
1762    fn test_content_files_parsed_in_order() {
1763        let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
1764        let files: Vec<&String> = config
1765            .content_files_iter()
1766            .map(FileSetting::value)
1767            .collect();
1768        assert_eq!(
1769            files,
1770            vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
1771        );
1772    }
1773
1774    #[test]
1775    fn test_has_content_file_found() {
1776        let config = load("content=Morrowind.esm\n");
1777        assert!(config.has_content_file("Morrowind.esm"));
1778    }
1779
1780    #[test]
1781    fn test_has_content_file_not_found() {
1782        let config = load("content=Morrowind.esm\n");
1783        assert!(!config.has_content_file("Tribunal.esm"));
1784    }
1785
1786    #[test]
1787    fn test_duplicate_content_file_errors_on_load() {
1788        let dir = temp_dir();
1789        write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1790        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1791    }
1792
1793    #[test]
1794    fn test_duplicate_content_file_error_reports_line_number() {
1795        let dir = temp_dir();
1796        write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
1797
1798        let result = OpenMWConfiguration::new(Some(dir));
1799        assert!(matches!(
1800            result,
1801            Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
1802        ));
1803    }
1804
1805    #[test]
1806    fn test_add_content_file_appends() {
1807        let mut config = load("content=Morrowind.esm\n");
1808        config.add_content_file("MyMod.esp").unwrap();
1809        assert!(config.has_content_file("MyMod.esp"));
1810    }
1811
1812    #[test]
1813    fn test_add_duplicate_content_file_errors() {
1814        let mut config = load("content=Morrowind.esm\n");
1815        assert!(config.add_content_file("Morrowind.esm").is_err());
1816    }
1817
1818    #[test]
1819    fn test_add_content_file_source_config_is_cfg_file() {
1820        let dir = temp_dir();
1821        let cfg_path = write_cfg(&dir, "");
1822        let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
1823        config.add_content_file("Mod.esp").unwrap();
1824        let setting = config.content_files_iter().next().unwrap();
1825        assert_eq!(
1826            setting.meta().source_config,
1827            cfg_path,
1828            "source_config should be the openmw.cfg file, not a directory"
1829        );
1830    }
1831
1832    #[test]
1833    fn test_remove_content_file() {
1834        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1835        config.remove_content_file("Morrowind.esm");
1836        assert!(!config.has_content_file("Morrowind.esm"));
1837        assert!(config.has_content_file("Tribunal.esm"));
1838    }
1839
1840    #[test]
1841    fn test_set_content_files_replaces_all() {
1842        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
1843        config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
1844        assert!(!config.has_content_file("Morrowind.esm"));
1845        assert!(!config.has_content_file("Tribunal.esm"));
1846        assert!(config.has_content_file("NewMod.esp"));
1847    }
1848
1849    #[test]
1850    fn test_set_content_files_none_clears_all() {
1851        let mut config = load("content=Morrowind.esm\n");
1852        config.set_content_files(None);
1853        assert!(config.content_files_iter().next().is_none());
1854    }
1855
1856    // -----------------------------------------------------------------------
1857    // Fallback archives
1858    // -----------------------------------------------------------------------
1859
1860    #[test]
1861    fn test_fallback_archives_parsed() {
1862        let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1863        let archives: Vec<&String> = config
1864            .fallback_archives_iter()
1865            .map(FileSetting::value)
1866            .collect();
1867        assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
1868    }
1869
1870    #[test]
1871    fn test_has_archive_file() {
1872        let config = load("fallback-archive=Morrowind.bsa\n");
1873        assert!(config.has_archive_file("Morrowind.bsa"));
1874        assert!(!config.has_archive_file("Tribunal.bsa"));
1875    }
1876
1877    #[test]
1878    fn test_add_duplicate_archive_errors() {
1879        let mut config = load("fallback-archive=Morrowind.bsa\n");
1880        assert!(config.add_archive_file("Morrowind.bsa").is_err());
1881    }
1882
1883    #[test]
1884    fn test_duplicate_archive_error_reports_line_number() {
1885        let dir = temp_dir();
1886        write_cfg(
1887            &dir,
1888            "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
1889        );
1890
1891        let result = OpenMWConfiguration::new(Some(dir));
1892        assert!(matches!(
1893            result,
1894            Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
1895        ));
1896    }
1897
1898    #[test]
1899    fn test_remove_archive_file() {
1900        let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
1901        config.remove_archive_file("Morrowind.bsa");
1902        assert!(!config.has_archive_file("Morrowind.bsa"));
1903        assert!(config.has_archive_file("Tribunal.bsa"));
1904    }
1905
1906    // -----------------------------------------------------------------------
1907    // Groundcover
1908    // -----------------------------------------------------------------------
1909
1910    #[test]
1911    fn test_groundcover_parsed() {
1912        let config = load("groundcover=GrassPlugin.esp\n");
1913        let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
1914        assert_eq!(grass, vec!["GrassPlugin.esp"]);
1915    }
1916
1917    #[test]
1918    fn test_has_groundcover_file() {
1919        let config = load("groundcover=Grass.esp\n");
1920        assert!(config.has_groundcover_file("Grass.esp"));
1921        assert!(!config.has_groundcover_file("Other.esp"));
1922    }
1923
1924    #[test]
1925    fn test_duplicate_groundcover_errors_on_load() {
1926        let dir = temp_dir();
1927        write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1928        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
1929    }
1930
1931    #[test]
1932    fn test_duplicate_groundcover_error_reports_line_number() {
1933        let dir = temp_dir();
1934        write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
1935
1936        let result = OpenMWConfiguration::new(Some(dir));
1937        assert!(matches!(
1938            result,
1939            Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
1940        ));
1941    }
1942
1943    // -----------------------------------------------------------------------
1944    // Data directories
1945    // -----------------------------------------------------------------------
1946
1947    #[test]
1948    fn test_data_directories_absolute_paths_parsed() {
1949        let config = load("data=/absolute/path/to/data\n");
1950        assert!(
1951            config
1952                .data_directories_iter()
1953                .any(|d| d.parsed().ends_with("absolute/path/to/data"))
1954        );
1955    }
1956
1957    #[test]
1958    fn test_add_data_directory() {
1959        let mut config = load("");
1960        config.add_data_directory(Path::new("/some/data/dir"));
1961        assert!(config.has_data_dir("/some/data/dir"));
1962    }
1963
1964    #[test]
1965    fn test_set_data_directories_replaces_all() {
1966        let mut config = load("data=/old/dir\n");
1967        config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
1968        assert!(!config.has_data_dir("/old/dir"));
1969        assert!(config.has_data_dir("/new/dir"));
1970    }
1971
1972    #[test]
1973    fn test_remove_data_directory() {
1974        let mut config = load("data=/keep/me\n");
1975        config.add_data_directory(Path::new("/remove/me"));
1976        config.remove_data_directory(&PathBuf::from("/remove/me"));
1977        assert!(!config.has_data_dir("/remove/me"));
1978        assert!(config.has_data_dir("/keep/me"));
1979    }
1980
1981    // -----------------------------------------------------------------------
1982    // Fallback (game) settings
1983    // -----------------------------------------------------------------------
1984
1985    #[test]
1986    fn test_game_settings_parsed() {
1987        let config = load("fallback=iMaxLevel,100\n");
1988        let setting = config.get_game_setting("iMaxLevel").unwrap();
1989        assert_eq!(setting.value(), "100");
1990    }
1991
1992    #[test]
1993    fn test_game_settings_last_wins() {
1994        let config = load("fallback=iKey,1\nfallback=iKey,2\n");
1995        let setting = config.get_game_setting("iKey").unwrap();
1996        assert_eq!(setting.value(), "2");
1997    }
1998
1999    #[test]
2000    fn test_game_settings_deduplicates_by_key() {
2001        // When the same fallback key appears more than once, game_settings() must emit only the
2002        // last-defined value (last-wins), matching the behavior of get_game_setting().
2003        let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2004        let results: Vec<_> = config
2005            .game_settings()
2006            .filter(|s| s.key() == "iKey")
2007            .collect();
2008        assert_eq!(
2009            results.len(),
2010            1,
2011            "game_settings() should deduplicate by key"
2012        );
2013        assert_eq!(results[0].value(), "2", "last-defined value should win");
2014    }
2015
2016    #[test]
2017    fn test_get_game_setting_missing_returns_none() {
2018        let config = load("fallback=iKey,1\n");
2019        assert!(config.get_game_setting("iMissing").is_none());
2020    }
2021
2022    #[test]
2023    fn test_game_setting_color_roundtrip() {
2024        let config = load("fallback=iSkyColor,100,149,237\n");
2025        let setting = config.get_game_setting("iSkyColor").unwrap();
2026        assert_eq!(setting.value(), "100,149,237");
2027    }
2028
2029    #[test]
2030    fn test_game_setting_float_roundtrip() {
2031        let config = load("fallback=fGravity,9.81\n");
2032        let setting = config.get_game_setting("fGravity").unwrap();
2033        assert_eq!(setting.value(), "9.81");
2034    }
2035
2036    #[test]
2037    fn test_invalid_game_setting_error_reports_line_number() {
2038        let dir = temp_dir();
2039        write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
2040
2041        let result = OpenMWConfiguration::new(Some(dir));
2042        assert!(matches!(
2043            result,
2044            Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
2045        ));
2046    }
2047
2048    // -----------------------------------------------------------------------
2049    // Encoding
2050    // -----------------------------------------------------------------------
2051
2052    #[test]
2053    fn test_encoding_parsed() {
2054        use crate::config::encodingsetting::EncodingType;
2055        let config = load("encoding=win1252\n");
2056        assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
2057    }
2058
2059    #[test]
2060    fn test_invalid_encoding_errors_on_load() {
2061        let dir = temp_dir();
2062        write_cfg(&dir, "encoding=utf8\n");
2063        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2064    }
2065
2066    #[test]
2067    fn test_invalid_encoding_error_reports_line_number() {
2068        let dir = temp_dir();
2069        write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
2070
2071        let result = OpenMWConfiguration::new(Some(dir));
2072        assert!(matches!(
2073            result,
2074            Err(ConfigError::BadEncoding { line: Some(2), .. })
2075        ));
2076    }
2077
2078    // -----------------------------------------------------------------------
2079    // Replace semantics
2080    // -----------------------------------------------------------------------
2081
2082    #[test]
2083    fn test_replace_content_clears_prior_plugins() {
2084        let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
2085        assert!(!config.has_content_file("Old.esm"));
2086        assert!(config.has_content_file("New.esm"));
2087    }
2088
2089    #[test]
2090    fn test_replace_data_clears_prior_dirs() {
2091        let config = load("data=/old\nreplace=data\ndata=/new\n");
2092        assert!(!config.has_data_dir("/old"));
2093        assert!(config.has_data_dir("/new"));
2094    }
2095
2096    #[test]
2097    fn test_replace_keeps_comment_adjacency() {
2098        let config = load("content=Old.esm\nreplace=content\n\n# keep me\ncontent=New.esm\n");
2099        let output = config.to_string();
2100
2101        assert!(!output.contains("Old.esm"));
2102        assert!(output.contains("# keep me\ncontent=New.esm"));
2103    }
2104
2105    // -----------------------------------------------------------------------
2106    // Display / serialisation
2107    // -----------------------------------------------------------------------
2108
2109    #[test]
2110    fn test_display_contains_version_comment() {
2111        let config = load("content=Morrowind.esm\n");
2112        let output = config.to_string();
2113        assert!(
2114            output.contains("# OpenMW-Config Serializer Version:"),
2115            "Display should include version comment"
2116        );
2117    }
2118
2119    #[test]
2120    fn test_display_preserves_content_entries() {
2121        let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2122        let output = config.to_string();
2123        assert!(output.contains("content=Morrowind.esm"));
2124        assert!(output.contains("content=Tribunal.esm"));
2125    }
2126
2127    #[test]
2128    fn test_display_preserves_comments() {
2129        let config = load("# This is a comment\ncontent=Morrowind.esm\n");
2130        let output = config.to_string();
2131        assert!(output.contains("# This is a comment"));
2132    }
2133
2134    // -----------------------------------------------------------------------
2135    // Generic settings
2136    // -----------------------------------------------------------------------
2137
2138    #[test]
2139    fn test_generic_setting_preserved() {
2140        let config = load("some-unknown-key=some-value\n");
2141        let output = config.to_string();
2142        assert!(output.contains("some-unknown-key=some-value"));
2143    }
2144
2145    #[test]
2146    fn test_generic_settings_can_be_replaced_and_iterated() {
2147        let mut config = load("no-sound=1\nno-sound=0\nother=keep\n");
2148
2149        config.set_generic_settings("no-sound", Some(vec!["2".to_string()]));
2150
2151        let values: Vec<(&str, &str)> = config
2152            .generic_settings_iter()
2153            .map(|setting| (setting.key(), setting.value()))
2154            .collect();
2155
2156        assert_eq!(values, vec![("other", "keep"), ("no-sound", "2")]);
2157    }
2158
2159    #[test]
2160    fn test_save_to_path_writes_exact_output_path() {
2161        let dir = temp_dir();
2162        write_cfg(&dir, "no-sound=1\ncontent=Morrowind.esm\n");
2163        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2164
2165        let out = temp_dir().join("imported-openmw.cfg");
2166        config.save_to_path(&out).unwrap();
2167
2168        let saved = std::fs::read_to_string(&out).unwrap();
2169        assert!(saved.contains("no-sound=1"));
2170        assert!(saved.contains("content=Morrowind.esm"));
2171    }
2172
2173    #[test]
2174    fn test_save_to_path_overwrites_read_only_existing_file() {
2175        #[cfg(unix)]
2176        {
2177            use std::os::unix::fs::PermissionsExt;
2178
2179            let dir = temp_dir();
2180            write_cfg(&dir, "no-sound=1\n");
2181            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2182
2183            let out = dir.join("export.cfg");
2184            std::fs::write(&out, "old=content\n").unwrap();
2185            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o444)).unwrap();
2186
2187            config.save_to_path(&out).unwrap();
2188
2189            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o644)).unwrap();
2190            let saved = std::fs::read_to_string(&out).unwrap();
2191            assert!(saved.contains("no-sound=1"));
2192            assert!(!saved.contains("old=content"));
2193        }
2194    }
2195
2196    #[test]
2197    fn test_save_to_path_rejects_unwritable_parent_directory() {
2198        #[cfg(unix)]
2199        {
2200            use std::os::unix::fs::PermissionsExt;
2201
2202            let dir = temp_dir();
2203            write_cfg(&dir, "no-sound=1\n");
2204            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2205
2206            let parent = temp_dir();
2207            std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
2208
2209            let out = parent.join("export.cfg");
2210            let result = config.save_to_path(&out);
2211
2212            std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
2213
2214            assert!(matches!(result, Err(ConfigError::NotWritable(_))));
2215        }
2216    }
2217
2218    // -----------------------------------------------------------------------
2219    // save_user
2220    // -----------------------------------------------------------------------
2221
2222    #[test]
2223    fn test_save_user_round_trips_content_files() {
2224        let dir = temp_dir();
2225        write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
2226        let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2227        config.add_content_file("Bloodmoon.esm").unwrap();
2228        config.save_user().unwrap();
2229
2230        let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
2231        let files: Vec<&String> = reloaded
2232            .content_files_iter()
2233            .map(FileSetting::value)
2234            .collect();
2235        assert!(files.contains(&&"Morrowind.esm".to_string()));
2236        assert!(files.contains(&&"Bloodmoon.esm".to_string()));
2237    }
2238
2239    #[test]
2240    fn test_save_user_does_not_persist_injected_data_local_as_data() {
2241        let dir = temp_dir();
2242        let data_local = dir.join("data-local");
2243        write_cfg(
2244            &dir,
2245            &format!(
2246                "data-local={}\ndata={}\n",
2247                data_local.display(),
2248                data_local.display()
2249            ),
2250        );
2251
2252        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2253        assert!(config.has_data_dir(&data_local.to_string_lossy()));
2254
2255        config.save_user().unwrap();
2256
2257        let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2258        assert_eq!(saved.matches("data-local=").count(), 1);
2259        assert_eq!(saved.matches("data=").count(), 1);
2260    }
2261
2262    #[test]
2263    fn test_composite_display_does_not_serialize_injected_data_local_as_data() {
2264        let dir = temp_dir();
2265        let data_local = dir.join("data-local");
2266        write_cfg(&dir, &format!("data-local={}\n", data_local.display()));
2267
2268        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2269        let serialized = config.to_string();
2270
2271        assert!(serialized.contains("data-local="));
2272        assert!(!serialized.contains("data="));
2273    }
2274
2275    #[test]
2276    fn test_display_preserves_directory_originals_for_round_trip_boundary() {
2277        let dir = temp_dir();
2278        write_cfg(
2279            &dir,
2280            "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\n",
2281        );
2282
2283        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2284        let serialized = config.to_string();
2285
2286        assert!(serialized.contains("data=Data Files"));
2287        assert!(serialized.contains("data-local=\"Local Data\""));
2288        assert!(serialized.contains("resources=?local?/resources"));
2289        assert!(serialized.contains("user-data=?userdata?"));
2290    }
2291
2292    #[test]
2293    fn test_to_resolved_string_emits_resolved_directory_paths() {
2294        let dir = temp_dir();
2295        write_cfg(
2296            &dir,
2297            "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\ncontent=Base.esm\nfallback-archive=Morrowind.bsa\n",
2298        );
2299
2300        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2301        let serialized = config.to_resolved_string();
2302
2303        assert!(!serialized.contains("data=Data Files"));
2304        assert!(!serialized.contains("data-local=\"Local Data\""));
2305        assert!(!serialized.contains("resources=?local?/resources"));
2306        assert!(!serialized.contains("userdata="));
2307        assert!(serialized.contains(&format!("data={}", dir.join("Data Files").display())));
2308        assert!(serialized.contains(&format!("data-local={}", dir.join("Local Data").display())));
2309        assert!(serialized.contains("user-data="));
2310        assert!(serialized.contains("content=Base.esm"));
2311        assert!(serialized.contains("fallback-archive=Morrowind.bsa"));
2312    }
2313
2314    #[test]
2315    fn test_to_resolved_string_excludes_config_replace_and_synthetic_data_local() {
2316        let root = temp_dir();
2317        let sub = temp_dir();
2318        let data_local = root.join("local-data");
2319        write_cfg(
2320            &root,
2321            &format!(
2322                "data-local={}\nconfig={}\ncontent=Root.esm\n",
2323                data_local.display(),
2324                sub.display()
2325            ),
2326        );
2327        write_cfg(&sub, "content=Sub.esm\n");
2328
2329        let mut config = OpenMWConfiguration::new(Some(root)).unwrap();
2330        config.add_generic_setting("replace", "data");
2331
2332        let serialized = config.to_resolved_string();
2333
2334        assert!(!serialized.contains("config="));
2335        assert!(!serialized.contains("replace="));
2336        assert_eq!(serialized.matches("data-local=").count(), 1);
2337        assert_eq!(serialized.matches("data=").count(), 0);
2338        assert!(serialized.contains("content=Root.esm"));
2339        assert!(serialized.contains("content=Sub.esm"));
2340    }
2341
2342    #[test]
2343    fn test_save_resolved_to_path_writes_relocated_safe_output() {
2344        let dir = temp_dir();
2345        let out = temp_dir().join("openmw.cfg");
2346        write_cfg(&dir, "data=Data Files\ncontent=Base.esm\n");
2347
2348        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2349        config.save_resolved_to_path(&out).unwrap();
2350
2351        let saved = std::fs::read_to_string(out).unwrap();
2352        assert!(saved.contains(&format!("data={}", dir.join("Data Files").display())));
2353        assert!(!saved.contains("data=Data Files"));
2354        assert!(saved.contains("content=Base.esm"));
2355    }
2356
2357    #[test]
2358    fn test_save_user_not_writable_returns_error() {
2359        // Only meaningful on Unix — skip on other platforms
2360        #[cfg(unix)]
2361        {
2362            use std::os::unix::fs::PermissionsExt;
2363            let dir = temp_dir();
2364            write_cfg(&dir, "content=Morrowind.esm\n");
2365            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2366
2367            // Make the directory read-only so we can't write openmw.cfg
2368            let cfg_path = dir.join("openmw.cfg");
2369            std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
2370
2371            let result = config.save_user();
2372            // Restore permissions before asserting so temp cleanup works
2373            std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
2374
2375            assert!(
2376                matches!(result, Err(ConfigError::NotWritable(_))),
2377                "expected NotWritable, got {result:?}"
2378            );
2379        }
2380    }
2381
2382    // -----------------------------------------------------------------------
2383    // save_subconfig
2384    // -----------------------------------------------------------------------
2385
2386    #[test]
2387    fn test_save_subconfig_rejects_unloaded_path() {
2388        let dir = temp_dir();
2389        write_cfg(&dir, "content=Morrowind.esm\n");
2390        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2391
2392        let fake_dir = temp_dir();
2393        let result = config.save_subconfig(&fake_dir);
2394        assert!(
2395            matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
2396            "expected SubconfigNotLoaded, got {result:?}"
2397        );
2398    }
2399
2400    #[test]
2401    fn test_save_subconfig_round_trips_settings() {
2402        let root_dir = temp_dir();
2403        let sub_dir = temp_dir();
2404        write_cfg(&sub_dir, "content=Plugin.esp\n");
2405        write_cfg(
2406            &root_dir,
2407            &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
2408        );
2409
2410        let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
2411        config.add_content_file("NewPlugin.esp").unwrap();
2412        config.save_subconfig(&sub_dir).unwrap();
2413
2414        let sub_cfg = sub_dir.join("openmw.cfg");
2415        let saved = std::fs::read_to_string(sub_cfg).unwrap();
2416        assert!(
2417            saved.contains("content=Plugin.esp"),
2418            "sub-config content preserved"
2419        );
2420    }
2421
2422    #[test]
2423    #[cfg(unix)]
2424    fn test_save_subconfig_accepts_equivalent_symlink_path() {
2425        let root_dir = temp_dir();
2426        let real_sub_dir = temp_dir();
2427        let linked_sub_dir = temp_dir().join("linked_subconfig");
2428
2429        std::os::unix::fs::symlink(&real_sub_dir, &linked_sub_dir).unwrap();
2430        write_cfg(&real_sub_dir, "content=Plugin.esp\n");
2431        write_cfg(
2432            &root_dir,
2433            &format!(
2434                "content=Morrowind.esm\nconfig={}\n",
2435                linked_sub_dir.display()
2436            ),
2437        );
2438
2439        let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
2440        config.save_subconfig(&real_sub_dir).unwrap();
2441
2442        let saved = std::fs::read_to_string(real_sub_dir.join("openmw.cfg")).unwrap();
2443        assert!(
2444            saved.contains("content=Plugin.esp"),
2445            "sub-config settings should match by filesystem identity, not symlink spelling"
2446        );
2447    }
2448
2449    // -----------------------------------------------------------------------
2450    // Empty and optional construction
2451    // -----------------------------------------------------------------------
2452
2453    #[test]
2454    fn test_new_empty_uses_directory_as_user_config_context() {
2455        let dir = temp_dir().join("not-created-yet");
2456
2457        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2458        config.add_content_file("EmptyStart.esp").unwrap();
2459        config.add_data_directory(Path::new("Data Files"));
2460        config
2461            .set_game_setting("fScale,1.0", None, &mut String::new())
2462            .unwrap();
2463
2464        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2465        assert_eq!(config.root_config_dir(), dir);
2466        assert_eq!(config.user_config_path(), config.root_config_dir());
2467
2468        let content = config.content_files_iter().next().unwrap();
2469        assert_eq!(content.meta().source_config, config.root_config_file());
2470
2471        let data = config.data_directories_iter().next().unwrap();
2472        assert_eq!(data.meta().source_config, config.root_config_file());
2473        assert_eq!(data.parsed(), &config.root_config_dir().join("Data Files"));
2474
2475        let game_setting = config.get_game_setting("fScale").unwrap();
2476        assert_eq!(game_setting.meta().source_config, config.root_config_file());
2477    }
2478
2479    #[test]
2480    fn test_new_empty_rejects_openmw_cfg_file_shaped_path() {
2481        let result = OpenMWConfiguration::new_empty(temp_dir().join("openmw.cfg"));
2482        assert!(matches!(result, Err(ConfigError::NotFileOrDirectory(_))));
2483    }
2484
2485    #[test]
2486    fn test_save_to_path_works_from_new_empty_config() {
2487        let dir = temp_dir().join("missing-config-dir");
2488        let out_dir = temp_dir();
2489        let out = out_dir.join("openmw.cfg");
2490
2491        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2492        config.add_content_file("Generated.esp").unwrap();
2493        config.save_to_path(&out).unwrap();
2494
2495        let saved = std::fs::read_to_string(out).unwrap();
2496        assert!(saved.contains("content=Generated.esp"));
2497    }
2498
2499    #[test]
2500    fn test_save_user_creates_missing_new_empty_config_directory() {
2501        let dir = temp_dir().join("missing-user-config-dir");
2502
2503        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2504        config.add_content_file("Generated.esp").unwrap();
2505        config.save_user().unwrap();
2506
2507        let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2508        assert!(saved.contains("content=Generated.esp"));
2509    }
2510
2511    #[test]
2512    fn test_load_optional_existing_cfg_matches_new() {
2513        let dir = temp_dir();
2514        let cfg = write_cfg(&dir, "content=Existing.esm\ndata=Data Files\n");
2515
2516        let from_new = OpenMWConfiguration::new(Some(cfg.clone())).unwrap();
2517        let from_optional = OpenMWConfiguration::load_optional(&cfg).unwrap();
2518
2519        assert_eq!(
2520            from_optional.root_config_file(),
2521            from_new.root_config_file()
2522        );
2523        assert_eq!(from_optional.to_string(), from_new.to_string());
2524    }
2525
2526    #[test]
2527    fn test_load_optional_missing_cfg_uses_parent_context() {
2528        let dir = temp_dir().join("cfg-parent");
2529        let cfg = dir.join("openmw.cfg");
2530
2531        let mut config = OpenMWConfiguration::load_optional(&cfg).unwrap();
2532        config.add_data_directory(Path::new("Data Files"));
2533
2534        assert_eq!(config.root_config_file(), cfg);
2535        assert_eq!(config.root_config_dir(), dir);
2536        assert_eq!(
2537            config.data_directories_iter().next().unwrap().parsed(),
2538            &config.root_config_dir().join("Data Files")
2539        );
2540    }
2541
2542    #[test]
2543    fn test_load_optional_existing_empty_dir_uses_dir_context() {
2544        let dir = temp_dir();
2545
2546        let mut config = OpenMWConfiguration::load_optional(&dir).unwrap();
2547        config.add_data_directory(Path::new("Data Files"));
2548
2549        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2550        assert_eq!(config.root_config_dir(), dir);
2551        assert_eq!(
2552            config.data_directories_iter().next().unwrap().parsed(),
2553            &config.root_config_dir().join("Data Files")
2554        );
2555    }
2556
2557    #[test]
2558    fn test_load_optional_missing_dir_uses_dir_context() {
2559        let dir = temp_dir().join("missing-dir");
2560
2561        let config = OpenMWConfiguration::load_optional(&dir).unwrap();
2562
2563        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2564        assert_eq!(config.root_config_dir(), dir);
2565    }
2566
2567    #[test]
2568    fn test_new_empty_relative_dir_anchors_to_current_dir_for_resolved_export() {
2569        let relative_dir = PathBuf::from(format!(
2570            "openmw-config-relative-empty-{}",
2571            std::process::id()
2572        ));
2573        let expected_dir = util::display_preserving_absolute(&relative_dir).unwrap();
2574
2575        let mut config = OpenMWConfiguration::new_empty(&relative_dir).unwrap();
2576        config.add_data_directory(Path::new("Data Files"));
2577
2578        assert_eq!(config.root_config_dir(), expected_dir);
2579        assert!(config.to_resolved_string().contains(&format!(
2580            "data={}",
2581            expected_dir.join("Data Files").display()
2582        )));
2583    }
2584
2585    #[test]
2586    fn test_load_optional_relative_missing_cfg_anchors_to_current_dir() {
2587        let relative_cfg = PathBuf::from(format!(
2588            "openmw-config-relative-missing-{}/openmw.cfg",
2589            std::process::id()
2590        ));
2591        let expected_dir =
2592            util::display_preserving_absolute(relative_cfg.parent().unwrap()).unwrap();
2593
2594        let mut config = OpenMWConfiguration::load_optional(&relative_cfg).unwrap();
2595        config.add_data_directory(Path::new("Data Files"));
2596
2597        assert_eq!(config.root_config_dir(), expected_dir);
2598        assert!(config.to_resolved_string().contains(&format!(
2599            "data={}",
2600            expected_dir.join("Data Files").display()
2601        )));
2602    }
2603
2604    #[test]
2605    fn test_path_level_directory_singleton_setters_use_user_config_context() {
2606        let dir = temp_dir();
2607        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
2608
2609        config.set_data_local_path("local-data");
2610        config.set_resources_path("resources-dir");
2611        config.set_user_data_path("user-data-dir");
2612
2613        assert_eq!(
2614            config.data_local().unwrap().meta().source_config,
2615            config.root_config_file()
2616        );
2617        assert_eq!(
2618            config.resources().unwrap().meta().source_config,
2619            config.root_config_file()
2620        );
2621        assert_eq!(
2622            config.userdata().unwrap().meta().source_config,
2623            config.root_config_file()
2624        );
2625        assert_eq!(
2626            config.data_local().unwrap().parsed(),
2627            &dir.join("local-data")
2628        );
2629        assert_eq!(
2630            config.resources().unwrap().parsed(),
2631            &dir.join("resources-dir")
2632        );
2633        assert_eq!(
2634            config.userdata().unwrap().parsed(),
2635            &dir.join("user-data-dir")
2636        );
2637
2638        let serialized = config.to_string();
2639        assert!(serialized.contains("data-local=local-data"));
2640        assert!(serialized.contains("resources=resources-dir"));
2641        assert!(serialized.contains("user-data=user-data-dir"));
2642        assert!(!serialized.contains("userdata="));
2643    }
2644
2645    #[test]
2646    fn test_repeated_path_level_singleton_setters_replace() {
2647        let dir = temp_dir();
2648        let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
2649
2650        config.set_data_local_path("first-local");
2651        config.set_data_local_path("second-local");
2652        config.set_resources_path("first-resources");
2653        config.set_resources_path("second-resources");
2654        config.set_user_data_path("first-user-data");
2655        config.set_user_data_path("second-user-data");
2656
2657        let serialized = config.to_string();
2658        assert_eq!(serialized.matches("data-local=").count(), 1);
2659        assert_eq!(serialized.matches("resources=").count(), 1);
2660        assert_eq!(serialized.matches("user-data=").count(), 1);
2661        assert!(serialized.contains("data-local=second-local"));
2662        assert!(serialized.contains("resources=second-resources"));
2663        assert!(serialized.contains("user-data=second-user-data"));
2664    }
2665
2666    #[test]
2667    fn test_path_level_directory_singleton_clearers_remove_values() {
2668        let dir = temp_dir();
2669        let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
2670
2671        config.set_data_local_path("local-data");
2672        config.set_resources_path("resources-dir");
2673        config.set_user_data_path("user-data-dir");
2674
2675        config.clear_data_local();
2676        config.clear_resources();
2677        config.clear_user_data();
2678
2679        assert!(config.data_local().is_none());
2680        assert!(config.resources().is_none());
2681        assert!(config.userdata().is_none());
2682    }
2683
2684    // -----------------------------------------------------------------------
2685    // from_env
2686    // -----------------------------------------------------------------------
2687
2688    #[test]
2689    fn test_from_env_openmw_config_dir() {
2690        let _guard = env_lock();
2691        let dir = temp_dir();
2692        write_cfg(&dir, "content=Morrowind.esm\n");
2693
2694        // SAFETY: tests that mutate env must not run concurrently with each other.
2695        // The test binary is single-threaded by default so this is acceptable.
2696        unsafe {
2697            clear_from_env_overrides();
2698            std::env::set_var("OPENMW_CONFIG_DIR", &dir);
2699        }
2700        let config = OpenMWConfiguration::from_env().unwrap();
2701        unsafe { clear_from_env_overrides() };
2702
2703        assert!(config.has_content_file("Morrowind.esm"));
2704        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
2705    }
2706
2707    #[test]
2708    fn test_from_env_openmw_config_file() {
2709        let _guard = env_lock();
2710        let dir = temp_dir();
2711        let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
2712
2713        unsafe {
2714            clear_from_env_overrides();
2715            std::env::set_var("OPENMW_CONFIG", &cfg);
2716        }
2717        let config = OpenMWConfiguration::from_env().unwrap();
2718        unsafe { clear_from_env_overrides() };
2719
2720        assert!(config.has_content_file("Tribunal.esm"));
2721        assert_eq!(config.root_config_file(), cfg);
2722    }
2723
2724    #[test]
2725    fn test_from_env_prefers_openmw_config_file_over_dir() {
2726        let _guard = env_lock();
2727        let file_dir = temp_dir();
2728        let dir_dir = temp_dir();
2729        let cfg = write_cfg(&file_dir, "content=FileWins.esm\n");
2730        write_cfg(&dir_dir, "content=DirLoses.esm\n");
2731
2732        unsafe {
2733            clear_from_env_overrides();
2734            std::env::set_var("OPENMW_CONFIG", &cfg);
2735            std::env::set_var("OPENMW_CONFIG_DIR", &dir_dir);
2736        }
2737
2738        let config = OpenMWConfiguration::from_env().unwrap();
2739        unsafe { clear_from_env_overrides() };
2740
2741        assert_eq!(config.root_config_file(), cfg);
2742        assert!(config.has_content_file("FileWins.esm"));
2743        assert!(!config.has_content_file("DirLoses.esm"));
2744    }
2745
2746    #[test]
2747    fn test_from_env_prefers_first_openmw_config_dir_entry() {
2748        let _guard = env_lock();
2749        let missing_dir = temp_dir().join("missing");
2750        let first_valid = temp_dir();
2751        let second_valid = temp_dir();
2752        write_cfg(&first_valid, "content=FirstValid.esm\n");
2753        write_cfg(&second_valid, "content=SecondValid.esm\n");
2754
2755        let separator = if cfg!(windows) { ";" } else { ":" };
2756        let path_list = format!(
2757            "{}{}{}{}{}",
2758            missing_dir.display(),
2759            separator,
2760            first_valid.display(),
2761            separator,
2762            second_valid.display()
2763        );
2764
2765        unsafe {
2766            clear_from_env_overrides();
2767            std::env::set_var("OPENMW_CONFIG_DIR", path_list);
2768        }
2769
2770        let config = OpenMWConfiguration::from_env().unwrap();
2771        unsafe { clear_from_env_overrides() };
2772
2773        assert_eq!(config.root_config_file(), first_valid.join("openmw.cfg"));
2774        assert!(config.has_content_file("FirstValid.esm"));
2775        assert!(!config.has_content_file("SecondValid.esm"));
2776    }
2777
2778    #[test]
2779    fn test_from_env_config_dir_skips_non_file_openmw_cfg_entry() {
2780        let _guard = env_lock();
2781        let invalid_dir = temp_dir();
2782        let valid_dir = temp_dir();
2783        std::fs::create_dir_all(invalid_dir.join("openmw.cfg")).unwrap();
2784        write_cfg(&valid_dir, "content=ActualFile.esm\n");
2785
2786        let separator = if cfg!(windows) { ";" } else { ":" };
2787        let path_list = format!(
2788            "{}{}{}",
2789            invalid_dir.display(),
2790            separator,
2791            valid_dir.display()
2792        );
2793
2794        unsafe {
2795            clear_from_env_overrides();
2796            std::env::set_var("OPENMW_CONFIG_DIR", path_list);
2797        }
2798
2799        let config = OpenMWConfiguration::from_env().unwrap();
2800        unsafe { clear_from_env_overrides() };
2801
2802        assert_eq!(config.root_config_file(), valid_dir.join("openmw.cfg"));
2803        assert!(config.has_content_file("ActualFile.esm"));
2804    }
2805
2806    #[test]
2807    fn test_from_env_uses_global_config_when_no_explicit_env() {
2808        let _guard = env_lock();
2809        let global_dir = temp_dir();
2810        let cfg = write_cfg(&global_dir, "content=GlobalRoot.esm\n");
2811
2812        unsafe {
2813            clear_from_env_overrides();
2814            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
2815        }
2816
2817        let config = OpenMWConfiguration::from_env().unwrap();
2818        unsafe { clear_from_env_overrides() };
2819
2820        assert_eq!(config.root_config_file(), cfg);
2821        assert!(config.has_content_file("GlobalRoot.esm"));
2822    }
2823
2824    #[test]
2825    fn test_from_env_errors_without_local_or_global_root_config() {
2826        let _guard = env_lock();
2827        let empty_global_dir = temp_dir();
2828
2829        unsafe {
2830            clear_from_env_overrides();
2831            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2832        }
2833
2834        let result = OpenMWConfiguration::from_env();
2835        unsafe { clear_from_env_overrides() };
2836
2837        assert!(
2838            matches!(result, Err(ConfigError::CannotFindRootConfig { .. })),
2839            "expected CannotFindRootConfig, got {result:?}"
2840        );
2841    }
2842
2843    #[test]
2844    #[cfg(not(windows))]
2845    fn test_from_env_or_user_config_falls_back_to_user_config() {
2846        let _guard = env_lock();
2847        let empty_global_dir = temp_dir();
2848        let test_home = temp_dir();
2849        let test_xdg_config_home = temp_dir();
2850        let old_home = std::env::var_os("HOME");
2851        let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
2852
2853        unsafe {
2854            clear_from_env_overrides();
2855            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2856            std::env::set_var("HOME", &test_home);
2857            std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
2858        }
2859
2860        let user_config_dir = crate::try_default_config_path().unwrap();
2861        std::fs::create_dir_all(&user_config_dir).unwrap();
2862        let user_cfg = write_cfg(&user_config_dir, "content=UserOnly.esm\n");
2863
2864        let result = OpenMWConfiguration::from_env_or_user_config();
2865        unsafe {
2866            clear_from_env_overrides();
2867        }
2868        restore_env_var("HOME", old_home);
2869        restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
2870
2871        let config = result.unwrap();
2872        assert_eq!(config.root_config_file(), user_cfg);
2873        assert!(config.has_content_file("UserOnly.esm"));
2874    }
2875
2876    #[test]
2877    #[cfg(not(windows))]
2878    fn test_from_env_or_user_config_errors_when_no_candidates_exist() {
2879        let _guard = env_lock();
2880        let empty_global_dir = temp_dir();
2881        let test_home = temp_dir();
2882        let test_xdg_config_home = temp_dir();
2883        let old_home = std::env::var_os("HOME");
2884        let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
2885
2886        unsafe {
2887            clear_from_env_overrides();
2888            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
2889            std::env::set_var("HOME", &test_home);
2890            std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
2891        }
2892
2893        let result = OpenMWConfiguration::from_env_or_user_config();
2894        unsafe {
2895            clear_from_env_overrides();
2896        }
2897        restore_env_var("HOME", old_home);
2898        restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
2899
2900        assert!(
2901            matches!(result, Err(ConfigError::CannotFindAnyConfig { .. })),
2902            "expected CannotFindAnyConfig, got {result:?}"
2903        );
2904    }
2905
2906    #[test]
2907    fn test_global_root_config_loads_config_chain() {
2908        let _guard = env_lock();
2909        let global_dir = temp_dir();
2910        let user_dir = temp_dir();
2911        write_cfg(&user_dir, "content=UserConfig.esm\n");
2912        let root = write_cfg(
2913            &global_dir,
2914            &format!("content=GlobalRoot.esm\nconfig={}\n", user_dir.display()),
2915        );
2916
2917        unsafe {
2918            clear_from_env_overrides();
2919            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
2920        }
2921
2922        let config = OpenMWConfiguration::from_env().unwrap();
2923        unsafe { clear_from_env_overrides() };
2924
2925        assert_eq!(config.root_config_file(), root);
2926        assert!(config.has_content_file("GlobalRoot.esm"));
2927        assert!(config.has_content_file("UserConfig.esm"));
2928    }
2929
2930    // -----------------------------------------------------------------------
2931    // ConfigError variants
2932    // -----------------------------------------------------------------------
2933
2934    #[test]
2935    fn test_error_duplicate_archive_file() {
2936        // The parser itself rejects duplicate fallback-archive= entries
2937        let dir = temp_dir();
2938        write_cfg(
2939            &dir,
2940            "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
2941        );
2942        let result = OpenMWConfiguration::new(Some(dir));
2943        assert!(matches!(
2944            result,
2945            Err(ConfigError::DuplicateArchiveFile { .. })
2946        ));
2947    }
2948
2949    #[test]
2950    fn test_error_cannot_add_groundcover_file() {
2951        let mut config = load("groundcover=GrassPlugin.esp\n");
2952        let result = config.add_groundcover_file("GrassPlugin.esp");
2953        assert!(matches!(
2954            result,
2955            Err(ConfigError::CannotAddGroundcoverFile { .. })
2956        ));
2957    }
2958
2959    #[test]
2960    fn test_error_cannot_find() {
2961        let result =
2962            OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
2963        assert!(matches!(
2964            result,
2965            Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
2966        ));
2967    }
2968
2969    #[test]
2970    fn test_error_io_from_conversion() {
2971        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
2972        let config_err: ConfigError = io_err.into();
2973        assert!(matches!(config_err, ConfigError::Io(_)));
2974    }
2975
2976    #[test]
2977    fn test_error_invalid_line() {
2978        // A line with no `=` separator should produce InvalidLine
2979        let result = OpenMWConfiguration::new(Some({
2980            let dir = temp_dir();
2981            write_cfg(&dir, "this_has_no_equals_sign\n");
2982            dir
2983        }));
2984        assert!(matches!(
2985            result,
2986            Err(ConfigError::InvalidLine { line: Some(1), .. })
2987        ));
2988    }
2989
2990    #[test]
2991    fn test_error_max_depth_exceeded() {
2992        // Build a self-referencing config chain that will hit the depth limit
2993        let dir = temp_dir();
2994        write_cfg(&dir, &format!("config={}\n", dir.display()));
2995        let result = OpenMWConfiguration::new(Some(dir));
2996        assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
2997    }
2998
2999    #[test]
3000    fn test_error_max_depth_exceeded_for_circular_chain() {
3001        let a = temp_dir();
3002        let b = temp_dir();
3003
3004        write_cfg(&a, &format!("config={}\n", b.display()));
3005        write_cfg(&b, &format!("config={}\n", a.display()));
3006
3007        let result = OpenMWConfiguration::new(Some(a));
3008        assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
3009    }
3010
3011    #[cfg(unix)]
3012    #[test]
3013    fn test_symlinked_config_dir_loads_like_real_path() {
3014        let real_dir = temp_dir();
3015        write_cfg(&real_dir, "content=Morrowind.esm\n");
3016
3017        let link_parent = temp_dir();
3018        let link_path = link_parent.join("symlinked-config");
3019        if link_path.exists() {
3020            let _ = std::fs::remove_file(&link_path);
3021            let _ = std::fs::remove_dir_all(&link_path);
3022        }
3023        symlink_dir(&real_dir, &link_path);
3024
3025        let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
3026
3027        assert!(config.has_content_file("Morrowind.esm"));
3028        assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
3029        assert_eq!(config.root_config_dir(), link_path);
3030    }
3031
3032    // -----------------------------------------------------------------------
3033    // settings_matching and clear_matching
3034    // -----------------------------------------------------------------------
3035
3036    #[test]
3037    fn test_settings_matching_filters_correctly() {
3038        let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
3039        let content_count = config
3040            .settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
3041            .count();
3042        assert_eq!(content_count, 1);
3043    }
3044
3045    #[test]
3046    fn test_clear_matching_removes_entries() {
3047        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
3048        config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
3049        assert_eq!(config.content_files_iter().count(), 0);
3050    }
3051
3052    // -----------------------------------------------------------------------
3053    // sub_configs and config chaining
3054    // -----------------------------------------------------------------------
3055
3056    #[test]
3057    fn test_sub_configs_iteration() {
3058        let root_dir = temp_dir();
3059        let sub_dir = temp_dir();
3060        write_cfg(&sub_dir, "content=Plugin.esp\n");
3061        write_cfg(
3062            &root_dir,
3063            &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3064        );
3065
3066        let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3067        assert_eq!(config.sub_configs().count(), 1);
3068        assert!(
3069            config.has_content_file("Plugin.esp"),
3070            "sub-config content visible in root"
3071        );
3072    }
3073
3074    #[test]
3075    fn test_config_chain_priority_order_for_data_lists_matches_openmw_docs_example() {
3076        let dir1 = temp_dir();
3077        let dir2 = temp_dir();
3078        let dir3 = temp_dir();
3079        let dir4 = temp_dir();
3080
3081        write_cfg(
3082            &dir1,
3083            &format!(
3084                "data=root-a\nconfig={}\nconfig={}\n",
3085                dir2.display(),
3086                dir3.display()
3087            ),
3088        );
3089        write_cfg(
3090            &dir2,
3091            &format!("data=branch-a\nconfig={}\n", dir4.display()),
3092        );
3093        write_cfg(&dir3, "data=sibling-a\n");
3094        write_cfg(&dir4, "data=leaf-a\n");
3095
3096        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3097        let actual: Vec<String> = config
3098            .data_directories_iter()
3099            .map(|setting| setting.original().clone())
3100            .collect();
3101
3102        assert_eq!(actual, vec!["root-a", "branch-a", "sibling-a", "leaf-a"]);
3103    }
3104
3105    #[test]
3106    fn test_replace_data_preserves_docs_priority_order_in_branching_chain() {
3107        let dir1 = temp_dir();
3108        let dir2 = temp_dir();
3109        let dir3 = temp_dir();
3110        let dir4 = temp_dir();
3111
3112        write_cfg(
3113            &dir1,
3114            &format!(
3115                "data=root-a\nconfig={}\nconfig={}\n",
3116                dir2.display(),
3117                dir3.display()
3118            ),
3119        );
3120        write_cfg(
3121            &dir2,
3122            &format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
3123        );
3124        write_cfg(&dir3, "data=sibling-a\n");
3125        write_cfg(&dir4, "data=leaf-a\n");
3126
3127        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3128        let actual: Vec<String> = config
3129            .data_directories_iter()
3130            .map(|setting| setting.original().clone())
3131            .collect();
3132
3133        assert_eq!(actual, vec!["branch-a", "sibling-a", "leaf-a"]);
3134    }
3135
3136    #[test]
3137    fn test_config_chain_priority_order_for_content_lists_matches_openmw_docs_example() {
3138        let dir1 = temp_dir();
3139        let dir2 = temp_dir();
3140        let dir3 = temp_dir();
3141        let dir4 = temp_dir();
3142
3143        write_cfg(
3144            &dir1,
3145            &format!(
3146                "content=Root.esm\nconfig={}\nconfig={}\n",
3147                dir2.display(),
3148                dir3.display()
3149            ),
3150        );
3151        write_cfg(
3152            &dir2,
3153            &format!("content=Branch.esm\nconfig={}\n", dir4.display()),
3154        );
3155        write_cfg(&dir3, "content=Sibling.esm\n");
3156        write_cfg(&dir4, "content=Leaf.esm\n");
3157
3158        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3159        let actual: Vec<String> = config
3160            .content_files_iter()
3161            .map(|setting| setting.value().clone())
3162            .collect();
3163
3164        assert_eq!(
3165            actual,
3166            vec!["Root.esm", "Branch.esm", "Sibling.esm", "Leaf.esm"],
3167            "content= should follow the same chain priority order as documented for config= traversal"
3168        );
3169    }
3170
3171    #[test]
3172    fn test_config_chain_priority_order_for_groundcover_lists_matches_openmw_docs_example() {
3173        let dir1 = temp_dir();
3174        let dir2 = temp_dir();
3175        let dir3 = temp_dir();
3176        let dir4 = temp_dir();
3177
3178        write_cfg(
3179            &dir1,
3180            &format!(
3181                "groundcover=Root.esp\nconfig={}\nconfig={}\n",
3182                dir2.display(),
3183                dir3.display()
3184            ),
3185        );
3186        write_cfg(
3187            &dir2,
3188            &format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
3189        );
3190        write_cfg(&dir3, "groundcover=Sibling.esp\n");
3191        write_cfg(&dir4, "groundcover=Leaf.esp\n");
3192
3193        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3194        let actual: Vec<String> = config
3195            .groundcover_iter()
3196            .map(|setting| setting.value().clone())
3197            .collect();
3198
3199        assert_eq!(
3200            actual,
3201            vec!["Root.esp", "Branch.esp", "Sibling.esp", "Leaf.esp"],
3202            "groundcover= should follow the same chain priority order as documented for config= traversal"
3203        );
3204    }
3205
3206    #[test]
3207    fn test_config_chain_priority_order_matches_openmw_docs_example() {
3208        let dir1 = temp_dir();
3209        let dir2 = temp_dir();
3210        let dir3 = temp_dir();
3211        let dir4 = temp_dir();
3212
3213        write_cfg(
3214            &dir1,
3215            &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3216        );
3217        write_cfg(
3218            &dir2,
3219            &format!("encoding=win1250\nconfig={}\n", dir4.display()),
3220        );
3221        write_cfg(&dir3, "encoding=win1251\n");
3222        write_cfg(&dir4, "encoding=win1252\n");
3223
3224        let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3225
3226        assert_eq!(
3227            config.encoding().unwrap().to_string().trim(),
3228            "encoding=win1252"
3229        );
3230        assert_eq!(config.user_config_path(), dir4);
3231    }
3232
3233    #[test]
3234    fn test_config_chain_priority_order_with_user_data_crosscheck() {
3235        let dir1 = temp_dir();
3236        let dir2 = temp_dir();
3237        let dir3 = temp_dir();
3238        let dir4 = temp_dir();
3239
3240        write_cfg(
3241            &dir1,
3242            &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3243        );
3244        write_cfg(
3245            &dir2,
3246            &format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
3247        );
3248        write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
3249        write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
3250
3251        let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3252
3253        assert_eq!(config.user_config_path(), dir4);
3254        assert_eq!(config.userdata().unwrap().parsed(), dir4.as_path());
3255    }
3256
3257    // -----------------------------------------------------------------------
3258    // root_config_file / root_config_dir
3259    // -----------------------------------------------------------------------
3260
3261    #[test]
3262    fn test_root_config_file_points_to_cfg() {
3263        let dir = temp_dir();
3264        write_cfg(&dir, "");
3265        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3266        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3267    }
3268
3269    #[test]
3270    fn test_root_config_dir_is_parent() {
3271        let dir = temp_dir();
3272        write_cfg(&dir, "");
3273        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3274        assert_eq!(config.root_config_dir(), dir);
3275    }
3276
3277    // -----------------------------------------------------------------------
3278    // Clone
3279    // -----------------------------------------------------------------------
3280
3281    #[test]
3282    fn test_clone_is_independent() {
3283        let mut original = load("content=Morrowind.esm\n");
3284        let mut cloned = original.clone();
3285        cloned.add_content_file("Tribunal.esm").unwrap();
3286        original.add_content_file("Bloodmoon.esm").unwrap();
3287        assert!(cloned.has_content_file("Tribunal.esm"));
3288        assert!(!cloned.has_content_file("Bloodmoon.esm"));
3289        assert!(original.has_content_file("Bloodmoon.esm"));
3290        assert!(!original.has_content_file("Tribunal.esm"));
3291    }
3292
3293    fn assert_indexes_consistent(config: &OpenMWConfiguration) {
3294        use std::collections::{HashMap, HashSet};
3295
3296        config.ensure_game_setting_indexes();
3297
3298        let scanned_content: HashSet<String> = config
3299            .settings
3300            .iter()
3301            .filter_map(|setting| match setting {
3302                SettingValue::ContentFile(file) => Some(file.value().clone()),
3303                _ => None,
3304            })
3305            .collect();
3306        let scanned_groundcover: HashSet<String> = config
3307            .settings
3308            .iter()
3309            .filter_map(|setting| match setting {
3310                SettingValue::Groundcover(file) => Some(file.value().clone()),
3311                _ => None,
3312            })
3313            .collect();
3314        let scanned_archives: HashSet<String> = config
3315            .settings
3316            .iter()
3317            .filter_map(|setting| match setting {
3318                SettingValue::BethArchive(file) => Some(file.value().clone()),
3319                _ => None,
3320            })
3321            .collect();
3322        let scanned_data_dirs: HashSet<PathBuf> = config
3323            .settings
3324            .iter()
3325            .filter_map(|setting| match setting {
3326                SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
3327                _ => None,
3328            })
3329            .collect();
3330
3331        let mut scanned_game_setting_last = HashMap::new();
3332        for (index, setting) in config.settings.iter().enumerate() {
3333            if let SettingValue::GameSetting(game_setting) = setting {
3334                scanned_game_setting_last.insert(game_setting.key().clone(), index);
3335            }
3336        }
3337
3338        let mut scanned_game_setting_order = Vec::new();
3339        let mut seen = HashSet::new();
3340        for (index, setting) in config.settings.iter().enumerate().rev() {
3341            if let SettingValue::GameSetting(game_setting) = setting
3342                && seen.insert(game_setting.key())
3343            {
3344                scanned_game_setting_order.push(index);
3345            }
3346        }
3347
3348        assert_eq!(config.indexed_content, scanned_content);
3349        assert_eq!(config.indexed_groundcover, scanned_groundcover);
3350        assert_eq!(config.indexed_archives, scanned_archives);
3351        assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
3352        assert_eq!(
3353            *config.indexed_game_setting_last.borrow(),
3354            scanned_game_setting_last
3355        );
3356        assert_eq!(
3357            *config.indexed_game_setting_order.borrow(),
3358            scanned_game_setting_order
3359        );
3360
3361        for file in &config.indexed_content {
3362            assert!(config.has_content_file(file));
3363        }
3364        for file in &config.indexed_groundcover {
3365            assert!(config.has_groundcover_file(file));
3366        }
3367        for file in &config.indexed_archives {
3368            assert!(config.has_archive_file(file));
3369        }
3370        for dir in &config.indexed_data_dirs {
3371            assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
3372        }
3373
3374        let iter_keys: Vec<String> = config
3375            .game_settings()
3376            .map(|setting| setting.key().clone())
3377            .collect();
3378        let expected_keys: Vec<String> = config
3379            .indexed_game_setting_order
3380            .borrow()
3381            .iter()
3382            .filter_map(|index| match &config.settings[*index] {
3383                SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
3384                _ => None,
3385            })
3386            .collect();
3387        assert_eq!(iter_keys, expected_keys);
3388
3389        for (key, index) in config.indexed_game_setting_last.borrow().iter() {
3390            let expected_value = match &config.settings[*index] {
3391                SettingValue::GameSetting(game_setting) => game_setting.value(),
3392                _ => unreachable!("game setting index points to non-game setting"),
3393            };
3394            assert_eq!(
3395                config.get_game_setting(key).map(GameSettingType::value),
3396                Some(expected_value)
3397            );
3398        }
3399    }
3400
3401    #[test]
3402    fn test_indexes_remain_coherent_through_mutations() {
3403        let mut config = load(
3404            "content=Morrowind.esm\n\
3405content=Tribunal.esm\n\
3406groundcover=Grass.esp\n\
3407data=/tmp/data\n\
3408fallback-archive=Morrowind.bsa\n\
3409fallback=iGamma,1.00\n",
3410        );
3411        assert_indexes_consistent(&config);
3412
3413        config.add_content_file("Bloodmoon.esm").unwrap();
3414        assert_indexes_consistent(&config);
3415
3416        config.remove_content_file("Tribunal.esm");
3417        assert_indexes_consistent(&config);
3418
3419        config.add_groundcover_file("Flora.esp").unwrap();
3420        assert_indexes_consistent(&config);
3421
3422        config.remove_groundcover_file("Grass.esp");
3423        assert_indexes_consistent(&config);
3424
3425        config.add_archive_file("Tribunal.bsa").unwrap();
3426        assert_indexes_consistent(&config);
3427
3428        config.remove_archive_file("Morrowind.bsa");
3429        assert_indexes_consistent(&config);
3430
3431        config.add_data_directory(Path::new("/tmp/extra-data"));
3432        assert_indexes_consistent(&config);
3433
3434        config.remove_data_directory(&PathBuf::from("/tmp/data"));
3435        assert_indexes_consistent(&config);
3436
3437        config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
3438        assert_indexes_consistent(&config);
3439
3440        config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
3441        assert_indexes_consistent(&config);
3442
3443        config
3444            .set_game_settings(Some(vec![
3445                "iFoo,10".to_string(),
3446                "iFoo,11".to_string(),
3447                "fBar,1.5".to_string(),
3448            ]))
3449            .unwrap();
3450        assert_indexes_consistent(&config);
3451
3452        let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
3453        assert!(err.is_err());
3454        assert_indexes_consistent(&config);
3455
3456        config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
3457        assert_indexes_consistent(&config);
3458    }
3459
3460    #[test]
3461    fn test_indexes_coherent_after_replace_during_load() {
3462        let config = load(
3463            "content=Root.esm\n\
3464replace=content\n\
3465content=AfterReplace.esm\n\
3466groundcover=GrassRoot.esp\n\
3467replace=groundcover\n\
3468groundcover=GrassAfter.esp\n\
3469fallback-archive=Root.bsa\n\
3470replace=fallback-archives\n\
3471fallback-archive=After.bsa\n\
3472fallback=iFoo,1\n\
3473replace=fallback\n\
3474fallback=iFoo,2\n",
3475        );
3476
3477        assert_indexes_consistent(&config);
3478        assert!(config.has_content_file("AfterReplace.esm"));
3479        assert!(!config.has_content_file("Root.esm"));
3480        assert!(config.has_groundcover_file("GrassAfter.esp"));
3481        assert!(!config.has_groundcover_file("GrassRoot.esp"));
3482        assert!(config.has_archive_file("After.bsa"));
3483        assert!(!config.has_archive_file("Root.bsa"));
3484        assert_eq!(
3485            config.get_game_setting("iFoo").map(GameSettingType::value),
3486            Some("2".into())
3487        );
3488    }
3489}