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};
12use error::bail_config;
13use std::collections::HashSet;
14
15pub mod directorysetting;
16use directorysetting::DirectorySetting;
17
18pub mod filesetting;
19use filesetting::FileSetting;
20
21pub mod gamesetting;
22use gamesetting::GameSettingType;
23
24pub mod genericsetting;
25use genericsetting::GenericSetting;
26
27pub mod encodingsetting;
28use encodingsetting::EncodingSetting;
29
30pub mod error;
31mod hash;
32use hash::{FxHashMap, FxHashSet};
33#[macro_use]
34mod singletonsetting;
35mod strings;
36mod util;
37
38/// A single parsed entry from an `openmw.cfg` file.
39///
40/// Every line in the file is represented as one of these variants. The variant
41/// determines both the key that appears in the file and how the value is interpreted.
42/// Unknown keys are preserved as [`SettingValue::Generic`] so that round-trip
43/// serialisation never silently drops unrecognised entries.
44#[derive(Clone, Debug)]
45#[non_exhaustive]
46pub enum SettingValue {
47    /// A `data=` entry specifying a VFS data directory.
48    DataDirectory(DirectorySetting),
49    /// A `fallback=` entry containing a Morrowind.ini-style key/value pair.
50    GameSetting(GameSettingType),
51    /// A `user-data=` entry (singleton) specifying the user data root.
52    UserData(DirectorySetting),
53    /// A `data-local=` entry (singleton) specifying the highest-priority data directory.
54    DataLocal(DirectorySetting),
55    /// A `resources=` entry (singleton) specifying the engine resources directory.
56    Resources(DirectorySetting),
57    /// An `encoding=` entry (singleton) specifying the text encoding (`win1250`/`win1251`/`win1252`).
58    Encoding(EncodingSetting),
59    /// A `config=` entry referencing another `openmw.cfg` directory in the chain.
60    SubConfiguration(DirectorySetting),
61    /// Any unrecognised `key=value` line, preserved verbatim.
62    Generic(GenericSetting),
63    /// A `replace=` entry. Loading applies it; serialization keeps it, so a saved file still
64    /// discards what its parents defined when it is loaded again.
65    Replace(GenericSetting),
66    /// A `content=` entry naming an ESP/ESM plugin file.
67    ContentFile(FileSetting),
68    /// A `fallback-archive=` entry naming a BSA archive file.
69    BethArchive(FileSetting),
70    /// A `groundcover=` entry naming a groundcover plugin file.
71    Groundcover(FileSetting),
72    /// Comment and blank lines after a file's last setting, kept so serialization reproduces
73    /// them; the serializer's own version stamp is never one.
74    TrailingComment(TrailingComment),
75}
76
77/// The comment and blank lines that end a file without a setting after them.
78#[derive(Debug, Clone)]
79pub struct TrailingComment {
80    meta: crate::GameSettingMeta,
81}
82
83impl TrailingComment {
84    /// The file the lines end; its comment is the lines themselves.
85    #[must_use]
86    pub fn meta(&self) -> &crate::GameSettingMeta {
87        &self.meta
88    }
89}
90
91impl Display for TrailingComment {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{}", self.meta.comment)
94    }
95}
96
97impl crate::GameSetting for TrailingComment {
98    fn meta(&self) -> &crate::GameSettingMeta {
99        &self.meta
100    }
101
102    fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
103        &mut self.meta
104    }
105}
106
107/// The comment line every serialization ends with; a loaded file's copy is dropped so the
108/// stamp never accumulates.
109const SERIALIZER_STAMP_PREFIX: &str = "# OpenMW-Config Serializer Version:";
110
111fn write_serializer_stamp(out: &mut impl std::fmt::Write) -> fmt::Result {
112    writeln!(
113        out,
114        "{SERIALIZER_STAMP_PREFIX} {}",
115        env!("CARGO_PKG_VERSION")
116    )
117}
118
119impl Display for SettingValue {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        let str = match self {
122            // Already whole, newline-terminated lines.
123            SettingValue::TrailingComment(trailing) => {
124                return write!(f, "{}", trailing.meta().comment);
125            }
126            SettingValue::Encoding(encoding_setting) => encoding_setting.to_string(),
127            SettingValue::UserData(userdata_setting) => format!(
128                "{}user-data={}",
129                userdata_setting.meta().comment,
130                userdata_setting.original()
131            ),
132            SettingValue::DataLocal(data_local_setting) => format!(
133                "{}data-local={}",
134                data_local_setting.meta().comment,
135                data_local_setting.original(),
136            ),
137            SettingValue::Resources(resources_setting) => format!(
138                "{}resources={}",
139                resources_setting.meta().comment,
140                resources_setting.original()
141            ),
142            SettingValue::GameSetting(game_setting) => game_setting.to_string(),
143            SettingValue::DataDirectory(data_directory) => format!(
144                "{}data={}",
145                data_directory.meta().comment,
146                data_directory.original()
147            ),
148            SettingValue::SubConfiguration(sub_config) => format!(
149                "{}config={}",
150                sub_config.meta().comment,
151                sub_config.original()
152            ),
153            SettingValue::Generic(generic) | SettingValue::Replace(generic) => generic.to_string(),
154            SettingValue::ContentFile(plugin) => {
155                format!("{}content={}", plugin.meta().comment, plugin.value())
156            }
157            SettingValue::BethArchive(archive) => {
158                format!(
159                    "{}fallback-archive={}",
160                    archive.meta().comment,
161                    archive.value(),
162                )
163            }
164            SettingValue::Groundcover(grass) => {
165                format!("{}groundcover={}", grass.meta().comment, grass.value())
166            }
167        };
168
169        writeln!(f, "{str}")
170    }
171}
172
173impl From<GameSettingType> for SettingValue {
174    fn from(g: GameSettingType) -> Self {
175        SettingValue::GameSetting(g)
176    }
177}
178
179impl From<DirectorySetting> for SettingValue {
180    fn from(d: DirectorySetting) -> Self {
181        SettingValue::DataDirectory(d)
182    }
183}
184
185impl SettingValue {
186    /// The file that defined the entry and the comment above it.
187    #[must_use]
188    pub fn meta(&self) -> &crate::GameSettingMeta {
189        match self {
190            SettingValue::BethArchive(setting)
191            | SettingValue::Groundcover(setting)
192            | SettingValue::ContentFile(setting) => setting.meta(),
193            SettingValue::UserData(setting)
194            | SettingValue::DataLocal(setting)
195            | SettingValue::DataDirectory(setting)
196            | SettingValue::Resources(setting)
197            | SettingValue::SubConfiguration(setting) => setting.meta(),
198            SettingValue::GameSetting(setting) => setting.meta(),
199            SettingValue::Encoding(setting) => setting.meta(),
200            SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta(),
201            SettingValue::TrailingComment(setting) => setting.meta(),
202        }
203    }
204
205    fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
206        match self {
207            SettingValue::BethArchive(setting)
208            | SettingValue::Groundcover(setting)
209            | SettingValue::ContentFile(setting) => setting.meta_mut(),
210            SettingValue::UserData(setting)
211            | SettingValue::DataLocal(setting)
212            | SettingValue::DataDirectory(setting)
213            | SettingValue::Resources(setting)
214            | SettingValue::SubConfiguration(setting) => setting.meta_mut(),
215            SettingValue::GameSetting(setting) => setting.meta_mut(),
216            SettingValue::Encoding(setting) => setting.meta_mut(),
217            SettingValue::Generic(setting) | SettingValue::Replace(setting) => setting.meta_mut(),
218            SettingValue::TrailingComment(setting) => setting.meta_mut(),
219        }
220    }
221
222    fn to_resolved_string(&self) -> Option<String> {
223        match self {
224            SettingValue::UserData(userdata_setting) => Some(format!(
225                "{}user-data={}\n",
226                userdata_setting.meta().comment,
227                userdata_setting.parsed().display()
228            )),
229            SettingValue::DataLocal(data_local_setting) => Some(format!(
230                "{}data-local={}\n",
231                data_local_setting.meta().comment,
232                data_local_setting.parsed().display()
233            )),
234            SettingValue::Resources(resources_setting) => Some(format!(
235                "{}resources={}\n",
236                resources_setting.meta().comment,
237                resources_setting.parsed().display()
238            )),
239            SettingValue::DataDirectory(data_directory) => Some(format!(
240                "{}data={}\n",
241                data_directory.meta().comment,
242                data_directory.parsed().display()
243            )),
244            SettingValue::SubConfiguration(_) | SettingValue::Replace(_) => None,
245            SettingValue::Generic(generic) if generic.key().eq_ignore_ascii_case("replace") => None,
246            _ => Some(self.to_string()),
247        }
248    }
249}
250
251/// Comment text in `openmw.cfg` form: lines that are not blank and do not start with `#` get a
252/// `# ` prefix, and a non-empty comment ends with a newline. Text the parser queued (already
253/// `#` lines and blank lines, newline-terminated) passes through unchanged.
254fn normalize_comment(comment: &str) -> String {
255    if comment.is_empty() {
256        return String::new();
257    }
258
259    let mut normalized = String::with_capacity(comment.len() + 2);
260    for line in comment.strip_suffix('\n').unwrap_or(comment).split('\n') {
261        if !line.trim().is_empty() && !line.trim_start().starts_with('#') {
262            normalized.push_str("# ");
263        }
264        normalized.push_str(line);
265        normalized.push('\n');
266    }
267    normalized
268}
269
270/// A fully-resolved `OpenMW` configuration chain.
271///
272/// Constructed by walking the `config=` chain starting from a root `openmw.cfg`, accumulating
273/// every setting from every file into a flat list.  The list preserves source attribution and
274/// comments so that [`save_user`](Self::save_user) can write back only the user-owned entries,
275/// and [`Display`](std::fmt::Display) can reproduce a valid, comment-preserving `openmw.cfg`.
276#[derive(Debug, Default, Clone)]
277pub struct OpenMWConfiguration {
278    root_config: PathBuf,
279    settings: Vec<SettingValue>,
280    chain: Vec<ConfigChainEntry>,
281    indexed_content: FxHashSet<String>,
282    indexed_groundcover: FxHashSet<String>,
283    indexed_archives: FxHashSet<String>,
284    indexed_data_dirs: FxHashSet<PathBuf>,
285    lists: ListIndex,
286    indexed_game_setting_last: RefCell<FxHashMap<String, usize>>,
287    indexed_game_setting_order: RefCell<Vec<usize>>,
288    game_setting_indexes_dirty: Cell<bool>,
289    /// The lists the user config must replace when saved: a list a parent contributed to was
290    /// replaced, or a parent's entry was removed from one. `save_user` writes each behind a
291    /// `replace=` line of the user config, followed by what the parents still contribute to it,
292    /// because it never writes the parents' files and a reload would otherwise bring back what
293    /// they said.
294    user_replaces: Vec<ListOption>,
295    /// The last directory of the chain after the root, whether or not it holds an `openmw.cfg`
296    /// yet, which `OpenMW` takes as the user's; `None` when the root is the only one.
297    user_config_dir: Option<PathBuf>,
298}
299
300/// A list option `replace=` can discard, which a user config may have to replace.
301#[derive(Debug, Clone, PartialEq, Eq)]
302enum ListOption {
303    Content,
304    Groundcover,
305    FallbackArchive,
306    Data,
307    Fallback,
308    /// Preserved generic entries with this key.
309    Generic(String),
310}
311
312impl ListOption {
313    /// The option's name, as `replace=` spells it.
314    fn name(&self) -> &str {
315        match self {
316            ListOption::Content => "content",
317            ListOption::Groundcover => "groundcover",
318            ListOption::FallbackArchive => "fallback-archive",
319            ListOption::Data => "data",
320            ListOption::Fallback => "fallback",
321            ListOption::Generic(key) => key,
322        }
323    }
324
325    /// Whether `setting` is an entry of this list. The data directories loading injects are
326    /// entries of the data list too, but no file declares them.
327    fn holds(&self, setting: &SettingValue) -> bool {
328        match (self, setting) {
329            (ListOption::Content, SettingValue::ContentFile(_))
330            | (ListOption::Groundcover, SettingValue::Groundcover(_))
331            | (ListOption::FallbackArchive, SettingValue::BethArchive(_))
332            | (ListOption::Data, SettingValue::DataDirectory(_))
333            | (ListOption::Fallback, SettingValue::GameSetting(_)) => true,
334            (ListOption::Generic(key), SettingValue::Generic(generic)) => generic.key() == key,
335            _ => false,
336        }
337    }
338
339    /// Whether `replace` is a `replace=` line for this list: its value is the option's name,
340    /// exactly, as `OpenMW` compares it.
341    fn is_replaced_by(&self, replace: &GenericSetting) -> bool {
342        replace.value() == self.name()
343    }
344}
345
346/// The option name `replace=` gives the list `setting` is an entry of, if it is one: `OpenMW`'s
347/// composing options, `replace` among them, and for unknown keys the key.
348fn list_name(setting: &SettingValue) -> Option<&str> {
349    match setting {
350        SettingValue::Replace(_) => Some("replace"),
351        SettingValue::ContentFile(_) => Some("content"),
352        SettingValue::Groundcover(_) => Some("groundcover"),
353        SettingValue::BethArchive(_) => Some("fallback-archive"),
354        SettingValue::DataDirectory(_) => Some("data"),
355        SettingValue::GameSetting(_) => Some("fallback"),
356        SettingValue::Generic(generic) => Some(generic.key()),
357        _ => None,
358    }
359}
360
361/// One config of the chain as read, before the configs after it discard any of its entries.
362struct ConfigFile {
363    /// Its settings in file order, each with its line, then its `config=` entries and the
364    /// comments that end it, which have none (0).
365    settings: Vec<(SettingValue, usize)>,
366    /// The option names its `replace=` lines give, as written.
367    replaces: Vec<String>,
368}
369
370impl ConfigFile {
371    /// Its `config=` entries, in file order.
372    fn sub_configs(&self) -> impl DoubleEndedIterator<Item = &DirectorySetting> {
373        self.settings
374            .iter()
375            .filter_map(|(setting, _)| match setting {
376                SettingValue::SubConfiguration(dir) => Some(dir),
377                _ => None,
378            })
379    }
380
381    /// Reads the whole file at `cfg_file_path`, as `OpenMW` parses a config before merging it.
382    #[allow(clippy::too_many_lines)]
383    fn read(cfg_file_path: &Path) -> Result<Self, ConfigError> {
384        let lines = read_to_string(cfg_file_path)?;
385        let source = cfg_file_path.to_path_buf();
386
387        let mut settings: Vec<(SettingValue, usize)> = Vec::new();
388        let mut replaces = Vec::new();
389        let mut queued_comment = String::new();
390        let mut sub_configs: Vec<(String, String)> = Vec::new();
391
392        for (index, line) in lines.lines().enumerate() {
393            let line_no = index + 1;
394            let trimmed = line.trim();
395
396            if trimmed.is_empty() {
397                queued_comment.push('\n');
398                continue;
399            } else if trimmed.starts_with('#') {
400                queued_comment.push_str(line);
401                queued_comment.push('\n');
402                continue;
403            }
404
405            let Some((key, value)) = trimmed.split_once('=') else {
406                bail_config!(invalid_line, trimmed.into(), source, line_no);
407            };
408
409            let key = key.trim();
410            let value = value.trim();
411            let comment = &mut queued_comment;
412
413            let setting = match key {
414                "content" => SettingValue::ContentFile(FileSetting::new(value, &source, comment)),
415                "groundcover" => {
416                    SettingValue::Groundcover(FileSetting::new(value, &source, comment))
417                }
418                "fallback-archive" => {
419                    SettingValue::BethArchive(FileSetting::new(value, &source, comment))
420                }
421                "fallback" => SettingValue::GameSetting(
422                    GameSettingType::try_from((value.to_owned(), source.clone(), comment))
423                        .map_err(|error| match error {
424                            ConfigError::InvalidGameSetting {
425                                value, config_path, ..
426                            } => ConfigError::InvalidGameSetting {
427                                value,
428                                config_path,
429                                line: Some(line_no),
430                            },
431                            _ => error,
432                        })?,
433                ),
434                "encoding" => {
435                    let encoding = EncodingSetting::try_from((value.to_owned(), &source, comment))
436                        .map_err(|error| match error {
437                            ConfigError::BadEncoding {
438                                value, config_path, ..
439                            } => ConfigError::BadEncoding {
440                                value,
441                                config_path,
442                                line: Some(line_no),
443                            },
444                            _ => error,
445                        })?;
446                    // A second encoding= takes the first's place, as setting one does.
447                    if let Some(first) = settings
448                        .iter_mut()
449                        .find(|(setting, _)| matches!(setting, SettingValue::Encoding(_)))
450                    {
451                        *first = (SettingValue::Encoding(encoding), line_no);
452                        continue;
453                    }
454                    SettingValue::Encoding(encoding)
455                }
456                "config" => {
457                    sub_configs.push((value.to_owned(), std::mem::take(comment)));
458                    continue;
459                }
460                "data" => SettingValue::DataDirectory(DirectorySetting::new(
461                    value,
462                    source.clone(),
463                    comment,
464                )),
465                "resources" => {
466                    SettingValue::Resources(DirectorySetting::new(value, source.clone(), comment))
467                }
468                "user-data" => {
469                    SettingValue::UserData(DirectorySetting::new(value, source.clone(), comment))
470                }
471                "data-local" => {
472                    SettingValue::DataLocal(DirectorySetting::new(value, source.clone(), comment))
473                }
474                "replace" => {
475                    replaces.push(value.to_owned());
476                    SettingValue::Replace(GenericSetting::new(key, value, &source, comment))
477                }
478                _ => SettingValue::Generic(GenericSetting::new(key, value, &source, comment)),
479            };
480            settings.push((setting, line_no));
481        }
482
483        for (subconfig_path, mut comment) in sub_configs {
484            let setting = DirectorySetting::new(subconfig_path, source.clone(), &mut comment);
485            settings.push((SettingValue::SubConfiguration(setting), 0));
486        }
487
488        // Lines after the last setting have nothing to attach to; keep them as the file's
489        // trailer, minus the stamp a previous serialization left.
490        let trailing: String = queued_comment
491            .split_inclusive('\n')
492            .filter(|line| !line.starts_with(SERIALIZER_STAMP_PREFIX))
493            .collect();
494        if !trailing.is_empty() {
495            settings.push((
496                SettingValue::TrailingComment(TrailingComment {
497                    meta: crate::GameSettingMeta {
498                        source_config: source,
499                        comment: trailing,
500                    },
501                }),
502                0,
503            ));
504        }
505
506        Ok(Self { settings, replaces })
507    }
508}
509
510/// Where the entries of each list kind sit in `settings`, in definition order, so the list
511/// iterators and indexed reads never scan the whole flat list. Rebuilt with the other indexes.
512#[derive(Debug, Default, Clone)]
513struct ListIndex {
514    content: Vec<usize>,
515    groundcover: Vec<usize>,
516    archives: Vec<usize>,
517    data_dirs: Vec<usize>,
518    sub_configs: Vec<usize>,
519    generic: Vec<usize>,
520}
521
522/// A list kind whose entries the bindings read by position.
523#[cfg(feature = "luau")]
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub(crate) enum ListKind {
526    Content,
527    Groundcover,
528    Archives,
529    DataDirectories,
530    SubConfigs,
531    Generic,
532}
533
534#[derive(Debug, Clone, Eq, PartialEq)]
535pub enum ConfigChainStatus {
536    Loaded,
537    SkippedMissing,
538}
539
540#[derive(Debug, Clone, Eq, PartialEq)]
541pub struct ConfigChainEntry {
542    path: PathBuf,
543    depth: usize,
544    status: ConfigChainStatus,
545}
546
547impl ConfigChainEntry {
548    #[must_use]
549    pub fn path(&self) -> &Path {
550        &self.path
551    }
552
553    #[must_use]
554    pub fn depth(&self) -> usize {
555        self.depth
556    }
557
558    #[must_use]
559    pub fn status(&self) -> &ConfigChainStatus {
560        &self.status
561    }
562}
563
564impl OpenMWConfiguration {
565    /// Makes the data directories no file declares match the settings that add them, as
566    /// loading would: `resources/vfs` first when `resources=` is set, the `data-local=`
567    /// directory last when that is. They have no source config, so no save writes them back as
568    /// `data=` lines, which would load as second copies of them.
569    fn inject_data_directories(&mut self) {
570        self.settings
571            .retain(|setting| !Self::is_synthetic_data_directory(setting));
572
573        let engine_vfs = self.resources().map(|resources| {
574            let mut engine_vfs = DirectorySetting::new(
575                resources.parsed().join("vfs").to_string_lossy(),
576                resources.meta.source_config.clone(),
577                &mut String::new(),
578            );
579            engine_vfs.meta.source_config = PathBuf::new();
580            engine_vfs
581        });
582        let data_local = self.data_local().map(|data_local| {
583            let mut data_local = data_local.clone();
584            data_local.meta.source_config = PathBuf::new();
585            data_local.meta.comment.clear();
586            data_local
587        });
588
589        if let Some(engine_vfs) = engine_vfs {
590            self.settings
591                .insert(0, SettingValue::DataDirectory(engine_vfs));
592        }
593        if let Some(data_local) = data_local {
594            self.settings.push(SettingValue::DataDirectory(data_local));
595        }
596    }
597
598    /// Rebuilds the lookup indexes after any change to `settings`, re-deriving the injected
599    /// data directories first so they follow the change.
600    fn rebuild_indexes(&mut self) {
601        self.inject_data_directories();
602
603        self.indexed_content.clear();
604        self.indexed_groundcover.clear();
605        self.indexed_archives.clear();
606        self.indexed_data_dirs.clear();
607        let lists = &mut self.lists;
608        lists.content.clear();
609        lists.groundcover.clear();
610        lists.archives.clear();
611        lists.data_dirs.clear();
612        lists.sub_configs.clear();
613        lists.generic.clear();
614
615        for (index, setting) in self.settings.iter().enumerate() {
616            match setting {
617                SettingValue::ContentFile(file) => {
618                    lists.content.push(index);
619                    self.indexed_content.insert(file.value().clone());
620                }
621                SettingValue::Groundcover(file) => {
622                    lists.groundcover.push(index);
623                    self.indexed_groundcover.insert(file.value().clone());
624                }
625                SettingValue::BethArchive(file) => {
626                    lists.archives.push(index);
627                    self.indexed_archives.insert(file.value().clone());
628                }
629                SettingValue::DataDirectory(dir) => {
630                    lists.data_dirs.push(index);
631                    self.indexed_data_dirs.insert(dir.parsed().to_path_buf());
632                }
633                SettingValue::SubConfiguration(_) => lists.sub_configs.push(index),
634                SettingValue::Generic(_) => lists.generic.push(index),
635                _ => {}
636            }
637        }
638
639        self.mark_game_setting_indexes_dirty();
640    }
641
642    /// The `content=` entries, by their positions in `self.lists`.
643    fn files_at<'a>(&'a self, positions: &'a [usize]) -> impl Iterator<Item = &'a FileSetting> {
644        positions
645            .iter()
646            .filter_map(move |&index| match &self.settings[index] {
647                SettingValue::ContentFile(file)
648                | SettingValue::Groundcover(file)
649                | SettingValue::BethArchive(file) => Some(file),
650                _ => None,
651            })
652    }
653
654    /// The directory entries, by their positions in `self.lists`.
655    fn directories_at<'a>(
656        &'a self,
657        positions: &'a [usize],
658    ) -> impl Iterator<Item = &'a DirectorySetting> {
659        positions
660            .iter()
661            .filter_map(move |&index| match &self.settings[index] {
662                SettingValue::DataDirectory(dir) | SettingValue::SubConfiguration(dir) => Some(dir),
663                _ => None,
664            })
665    }
666
667    /// The positions of every `kind` entry in the flat settings list, in definition order.
668    #[cfg(feature = "luau")]
669    pub(crate) fn list_positions(&self, kind: ListKind) -> &[usize] {
670        match kind {
671            ListKind::Content => &self.lists.content,
672            ListKind::Groundcover => &self.lists.groundcover,
673            ListKind::Archives => &self.lists.archives,
674            ListKind::DataDirectories => &self.lists.data_dirs,
675            ListKind::SubConfigs => &self.lists.sub_configs,
676            ListKind::Generic => &self.lists.generic,
677        }
678    }
679
680    /// The setting at `index` of the flat list.
681    #[cfg(feature = "luau")]
682    pub(crate) fn setting_at(&self, index: usize) -> Option<&SettingValue> {
683        self.settings.get(index)
684    }
685
686    /// The flat-list position of the effective (last-defined) `fallback=` entry for `key`.
687    #[cfg(feature = "luau")]
688    pub(crate) fn game_setting_position(&self, key: &str) -> Option<usize> {
689        self.ensure_game_setting_indexes();
690        self.indexed_game_setting_last.borrow().get(key).copied()
691    }
692
693    /// The flat-list position of the `index`th entry [`Self::game_settings`] yields.
694    #[cfg(feature = "luau")]
695    pub(crate) fn game_setting_position_at(&self, index: usize) -> Option<usize> {
696        self.ensure_game_setting_indexes();
697        self.indexed_game_setting_order.borrow().get(index).copied()
698    }
699
700    /// The `index`th chain entry [`Self::config_chain`] yields.
701    #[cfg(feature = "luau")]
702    pub(crate) fn chain_entry(&self, index: usize) -> Option<&ConfigChainEntry> {
703        self.chain.get(index)
704    }
705
706    /// How many `content=` entries there are.
707    #[must_use]
708    pub fn content_file_count(&self) -> usize {
709        self.lists.content.len()
710    }
711
712    /// How many `groundcover=` entries there are.
713    #[must_use]
714    pub fn groundcover_file_count(&self) -> usize {
715        self.lists.groundcover.len()
716    }
717
718    /// How many `fallback-archive=` entries there are.
719    #[must_use]
720    pub fn archive_file_count(&self) -> usize {
721        self.lists.archives.len()
722    }
723
724    /// How many `data=` entries there are, the injected ones included.
725    #[must_use]
726    pub fn data_directory_count(&self) -> usize {
727        self.lists.data_dirs.len()
728    }
729
730    /// How many `config=` entries remain in the effective chain.
731    #[must_use]
732    pub fn sub_config_count(&self) -> usize {
733        self.lists.sub_configs.len()
734    }
735
736    /// How many preserved generic `key=value` entries there are.
737    #[must_use]
738    pub fn generic_setting_count(&self) -> usize {
739        self.lists.generic.len()
740    }
741
742    /// How many distinct `fallback=` keys there are (what [`Self::game_settings`] yields).
743    #[must_use]
744    pub fn game_setting_count(&self) -> usize {
745        self.ensure_game_setting_indexes();
746        self.indexed_game_setting_order.borrow().len()
747    }
748
749    fn mark_game_setting_indexes_dirty(&self) {
750        self.game_setting_indexes_dirty.set(true);
751        self.indexed_game_setting_last.borrow_mut().clear();
752        self.indexed_game_setting_order.borrow_mut().clear();
753    }
754
755    fn ensure_game_setting_indexes(&self) {
756        if !self.game_setting_indexes_dirty.get() {
757            return;
758        }
759
760        let mut last = FxHashMap::default();
761        for (index, setting) in self.settings.iter().enumerate() {
762            if let SettingValue::GameSetting(game_setting) = setting {
763                last.insert(game_setting.key().clone(), index);
764            }
765        }
766
767        let mut seen = HashSet::new();
768        let mut order = Vec::new();
769        for (index, setting) in self.settings.iter().enumerate().rev() {
770            if let SettingValue::GameSetting(game_setting) = setting
771                && seen.insert(game_setting.key())
772            {
773                order.push(index);
774            }
775        }
776
777        *self.indexed_game_setting_last.borrow_mut() = last;
778        *self.indexed_game_setting_order.borrow_mut() = order;
779        self.game_setting_indexes_dirty.set(false);
780    }
781
782    /// # Errors
783    /// Returns [`ConfigError`] if the path from the environment variable is invalid or if config loading fails.
784    ///
785    /// Discovery order matches `OpenMW` root config startup semantics:
786    ///
787    /// 1. `OPENMW_CONFIG`, an explicit `openmw.cfg` file path.
788    /// 2. `OPENMW_CONFIG_DIR`, a path list of directories containing `openmw.cfg`.
789    /// 3. Executable-adjacent `openmw.cfg`.
790    /// 4. Platform global config `openmw.cfg`.
791    ///
792    /// This deliberately does not fall back to `Self::new(None)`. User config is loaded only if
793    /// the discovered root config references it, usually through `config="?userconfig?"`.
794    ///
795    /// # Example
796    /// ```no_run
797    /// use openmw_config::OpenMWConfiguration;
798    /// let config = OpenMWConfiguration::from_env()?;
799    /// # Ok::<(), openmw_config::ConfigError>(())
800    /// ```
801    pub fn from_env() -> Result<Self, ConfigError> {
802        if let Ok(explicit_path) = std::env::var("OPENMW_CONFIG") {
803            let explicit_path = util::expand_leading_tilde(&explicit_path);
804
805            if explicit_path.as_os_str().is_empty() {
806                return Err(ConfigError::NotFileOrDirectory(explicit_path));
807            } else if explicit_path.is_absolute() {
808                return Self::new(Some(explicit_path));
809            } else if explicit_path.is_relative() {
810                return Self::new(Some(util::display_preserving_absolute(&explicit_path)?));
811            }
812            return Err(ConfigError::NotFileOrDirectory(explicit_path));
813        } else if let Ok(path_list) = std::env::var("OPENMW_CONFIG_DIR") {
814            let path_list = if cfg!(windows) {
815                path_list.split(';')
816            } else {
817                path_list.split(':')
818            };
819
820            for dir in path_list {
821                let dir = util::expand_leading_tilde(dir);
822                let config_file = dir.join("openmw.cfg");
823
824                if config_file.is_file() {
825                    return Self::new(Some(dir));
826                }
827            }
828        }
829
830        let root_config = crate::try_default_root_config_path()?;
831        Self::new(Some(root_config))
832    }
833
834    /// Loads an `OpenMW` configuration using environment/root discovery with a user-config fallback.
835    ///
836    /// This is the discovery mode external tools normally want:
837    ///
838    /// 1. Honor [`Self::from_env`] semantics, including `OPENMW_CONFIG`, `OPENMW_CONFIG_DIR`,
839    ///    executable-adjacent root config, and platform global root config.
840    /// 2. If strict root discovery finds no root config, fall back to the default user
841    ///    `openmw.cfg` (`?userconfig?/openmw.cfg`).
842    ///
843    /// Explicit environment errors are still errors. This method only adds a fallback for the
844    /// "no local/global root config exists" case.
845    ///
846    /// # Errors
847    /// Returns [`ConfigError`] if config loading fails or if no root/user config exists.
848    pub fn from_env_or_user_config() -> Result<Self, ConfigError> {
849        match Self::from_env() {
850            Ok(config) => Ok(config),
851            Err(ConfigError::CannotFindRootConfig { local, global }) => {
852                let user = crate::try_default_user_config_file()?;
853
854                if user.is_file() {
855                    Self::new(Some(user))
856                } else {
857                    Err(ConfigError::CannotFindAnyConfig {
858                        local,
859                        global,
860                        user,
861                    })
862                }
863            }
864            Err(error) => Err(error),
865        }
866    }
867
868    /// Loading creates the user config directory, [`Self::user_config_path`], when it is
869    /// missing, as `OpenMW`'s engine does at every start; it writes no `openmw.cfg` there.
870    ///
871    /// # Errors
872    /// Returns [`ConfigError`] if the path does not exist, is not a valid config, or if loading the config chain fails.
873    /// Returns [`ConfigError::NotWritable`] if the user config directory is missing and cannot
874    /// be created, where `OpenMW` stops with a fatal error.
875    ///
876    /// # Example
877    /// ```no_run
878    /// use std::path::PathBuf;
879    /// use openmw_config::OpenMWConfiguration;
880    ///
881    /// // Platform/user config default (?userconfig?/openmw.cfg), not OpenMW root discovery.
882    /// let config = OpenMWConfiguration::new(None)?;
883    ///
884    /// // Specific directory or file path — both are accepted
885    /// let config = OpenMWConfiguration::new(Some(PathBuf::from("/home/user/.config/openmw")))?;
886    /// # Ok::<(), openmw_config::ConfigError>(())
887    /// ```
888    pub fn new(path: Option<PathBuf>) -> Result<Self, ConfigError> {
889        let mut config = OpenMWConfiguration::default();
890        let root_config = match path {
891            Some(path) => util::input_config_path(path)?,
892            None => crate::try_default_config_path()?.join("openmw.cfg"),
893        };
894
895        config.root_config = root_config;
896
897        if let Err(error) = config.load(&config.root_config.clone()) {
898            Err(error)
899        } else {
900            if let Some(dir) = config.data_local() {
901                let path = dir.parsed();
902
903                let path_meta = metadata(path);
904                if path_meta.is_err()
905                    && let Err(error) = create_dir_all(path)
906                {
907                    util::debug_log(&format!(
908                        "WARNING: Attempted to create a data-local directory at {}, but failed: {error}",
909                        path.display()
910                    ));
911                }
912            }
913
914            util::debug_log_lazy(|| format!("{:#?}", config.settings));
915
916            Ok(config)
917        }
918    }
919
920    /// Creates an empty configuration rooted at `user_config_dir` without reading from disk.
921    ///
922    /// The argument is a configuration directory, not an `openmw.cfg` file path. New settings are
923    /// attributed to `user_config_dir/openmw.cfg`, and save/export APIs treat that file as the user
924    /// config. The directory and file do not need to exist yet.
925    ///
926    /// # Errors
927    /// Returns [`ConfigError::NotFileOrDirectory`] if `user_config_dir` is empty or is shaped like
928    /// an `openmw.cfg` file path. This keeps the directory-level API honest for future config-family
929    /// files such as `settings.cfg` and `shaders.yaml`.
930    pub fn new_empty(user_config_dir: impl Into<PathBuf>) -> Result<Self, ConfigError> {
931        let mut user_config_dir = user_config_dir.into();
932
933        if user_config_dir.as_os_str().is_empty()
934            || user_config_dir
935                .file_name()
936                .is_some_and(|file_name| file_name == "openmw.cfg")
937        {
938            return Err(ConfigError::NotFileOrDirectory(user_config_dir));
939        }
940
941        if user_config_dir.is_relative() {
942            user_config_dir = util::display_preserving_absolute(&user_config_dir)?;
943        }
944
945        Ok(Self {
946            root_config: user_config_dir.join("openmw.cfg"),
947            ..Self::default()
948        })
949    }
950
951    /// Loads a configuration if it exists, otherwise creates an empty configuration at the same
952    /// config context.
953    ///
954    /// Existing paths behave exactly like [`Self::new`]. A missing `openmw.cfg` file path starts an
955    /// empty config from its parent directory; a missing directory path starts an empty config from
956    /// that directory. No input file is created unless a later save call writes one.
957    ///
958    /// # Errors
959    /// Returns [`ConfigError`] for empty paths, invalid existing inputs, or load failures from
960    /// existing configs.
961    pub fn load_optional(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
962        let path = path.into();
963
964        if path.as_os_str().is_empty() {
965            return Err(ConfigError::NotFileOrDirectory(path));
966        }
967
968        if path.is_dir() && !path.join("openmw.cfg").exists() {
969            return Self::new_empty(path);
970        }
971
972        if path.exists() {
973            return Self::new(Some(path));
974        }
975
976        if path
977            .file_name()
978            .is_some_and(|file_name| file_name == "openmw.cfg")
979        {
980            let parent = path
981                .parent()
982                .filter(|parent| !parent.as_os_str().is_empty())
983                .unwrap_or_else(|| Path::new("."));
984            return Self::new_empty(parent);
985        }
986
987        Self::new_empty(path)
988    }
989
990    /// Path to the configuration file which is the root of the configuration chain
991    /// For [`Self::from_env`], this is the root discovered by `OpenMW` startup semantics: explicit
992    /// environment override, executable-adjacent `openmw.cfg`, or platform global config. It is not
993    /// necessarily the user config; package installs usually load the user config through a root
994    /// `config="?userconfig?"` entry.
995    ///
996    /// Typically, this will be whatever is defined in the `Paths` documentation for the appropriate platform:
997    /// <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-files-and-log-files>
998    #[must_use]
999    pub fn root_config_file(&self) -> &std::path::Path {
1000        &self.root_config
1001    }
1002
1003    /// Same as `root_config_file`, but returns the directory it's in.
1004    /// Useful for reading other configuration files, or if assuming openmw.cfg
1005    /// Is always *called* openmw.cfg (which it should be)
1006    ///
1007    /// # Panics
1008    /// Panics if the root config path has no parent directory (i.e. it is a filesystem root).
1009    #[must_use]
1010    pub fn root_config_dir(&self) -> PathBuf {
1011        self.root_config
1012            .parent()
1013            .expect("root_config has no parent directory")
1014            .to_path_buf()
1015    }
1016
1017    #[must_use]
1018    pub fn is_user_config(&self) -> bool {
1019        util::paths_equivalent(&self.root_config_dir(), &self.user_config_path())
1020    }
1021
1022    /// The user's config on its own, loaded from [`Self::user_config_path`] as
1023    /// [`Self::load_optional`] loads: empty and rooted there when its `openmw.cfg` does not
1024    /// exist yet, as on a fresh install. `self` when it already starts there.
1025    ///
1026    /// # Errors
1027    /// Returns [`ConfigError`] if the user config path cannot be loaded.
1028    pub fn user_config(self) -> Result<Self, ConfigError> {
1029        let user_path = self.user_config_path();
1030        if util::paths_equivalent(&self.root_config_dir(), &user_path) {
1031            Ok(self)
1032        } else {
1033            Self::load_optional(user_path)
1034        }
1035    }
1036
1037    /// [`Self::user_config`] without consuming `self`: a clone when it already starts at the
1038    /// user's config.
1039    ///
1040    /// # Errors
1041    /// Returns [`ConfigError`] if the user config path cannot be loaded.
1042    pub fn user_config_ref(&self) -> Result<Self, ConfigError> {
1043        let user_path = self.user_config_path();
1044        if util::paths_equivalent(&self.root_config_dir(), &user_path) {
1045            Ok(self.clone())
1046        } else {
1047            Self::load_optional(user_path)
1048        }
1049    }
1050
1051    /// The directory of the user's `openmw.cfg`: the last directory of the chain, or the root's
1052    /// when nothing chained. That is the file `OpenMW` and its launcher write, and the one
1053    /// [`Self::save_user`] writes; [`Self::user_config`] loads it on its own.
1054    ///
1055    /// A `config=` directory without an `openmw.cfg` counts: `readConfiguration` adds every
1056    /// directory it reaches to its active config paths, loaded or not. So on a fresh install,
1057    /// where the root's `config="?userconfig?"` names a directory with no `openmw.cfg` yet, this is
1058    /// that directory. Loading creates it, as `OpenMW`'s engine does, and [`Self::save_user`]
1059    /// writes its `openmw.cfg`.
1060    ///
1061    /// `config=` entries load depth first, as `OpenMW`'s `readConfiguration` walks them: a file's
1062    /// first `config=` and every file that one names load before its second. A root naming `a`
1063    /// then `b`, where `a` names `c`, loads `root`, `a`, `c`, `b`, and `b` is the user's.
1064    /// `OpenMW`'s paths documentation describes that example level by level (`root`, `a`, `b`,
1065    /// `c`); this crate does what `OpenMW`'s code does.
1066    /// A `replace=config` in a file after the root drops the configs loaded before it except the
1067    /// root, as `OpenMW` does; the chain carries on through every `config=` still to load.
1068    ///
1069    /// See <https://openmw.readthedocs.io/en/latest/reference/modding/paths.html#configuration-sources>
1070    /// for the configuration sources.
1071    #[must_use]
1072    pub fn user_config_path(&self) -> PathBuf {
1073        self.user_config_dir
1074            .clone()
1075            .unwrap_or_else(|| self.root_config_dir())
1076    }
1077
1078    impl_singleton_setting! {
1079        UserData => {
1080            get: userdata,
1081            set: set_userdata,
1082            in_type: DirectorySetting
1083        },
1084        Resources => {
1085            get: resources,
1086            set: set_resources,
1087            in_type: DirectorySetting
1088        },
1089        DataLocal => {
1090            get: data_local,
1091            set: set_data_local,
1092            in_type: DirectorySetting
1093        },
1094        Encoding => {
1095            get: encoding,
1096            set: set_encoding,
1097            in_type: EncodingSetting
1098        }
1099    }
1100
1101    fn directory_setting_for_user_config(&self, path: impl AsRef<Path>) -> DirectorySetting {
1102        DirectorySetting::new(
1103            path.as_ref().to_string_lossy(),
1104            self.user_config_path().join("openmw.cfg"),
1105            &mut String::default(),
1106        )
1107    }
1108
1109    /// Sets the singleton `data-local=` directory to a path attributed to the user config: the user
1110    /// config's definition is replaced when it is the last one, and a parent's stays, followed
1111    /// by the new one.
1112    ///
1113    /// The value is parsed through [`DirectorySetting`] so quote, token, and relative-path handling
1114    /// remains centralized.
1115    pub fn set_data_local_path(&mut self, path: impl AsRef<Path>) {
1116        let setting = self.directory_setting_for_user_config(path);
1117        self.set_data_local(Some(setting));
1118    }
1119
1120    /// Sets the singleton `resources=` directory to a path attributed to the user config: the user
1121    /// config's definition is replaced when it is the last one, and a parent's stays, followed
1122    /// by the new one.
1123    ///
1124    /// The value is parsed through [`DirectorySetting`] so quote, token, and relative-path handling
1125    /// remains centralized.
1126    pub fn set_resources_path(&mut self, path: impl AsRef<Path>) {
1127        let setting = self.directory_setting_for_user_config(path);
1128        self.set_resources(Some(setting));
1129    }
1130
1131    /// Sets the singleton `user-data=` directory to a path attributed to the user config: the user
1132    /// config's definition is replaced when it is the last one, and a parent's stays, followed
1133    /// by the new one.
1134    ///
1135    /// The cfg key is `user-data`; `?userdata?` is only a token. There is no `userdata=` key here.
1136    pub fn set_user_data_path(&mut self, path: impl AsRef<Path>) {
1137        let setting = self.directory_setting_for_user_config(path);
1138        self.set_userdata(Some(setting));
1139    }
1140
1141    /// Removes the last `data-local=` definition, which lets an earlier one, such as a parent's,
1142    /// take effect. [`Self::save_user`] persists removing the user config's own; a parent's can
1143    /// only be removed from its file, with [`Self::save_subconfig`].
1144    pub fn clear_data_local(&mut self) {
1145        self.set_data_local(None);
1146    }
1147
1148    /// Removes the last `resources=` definition, which lets an earlier one, such as a parent's,
1149    /// take effect. [`Self::save_user`] persists removing the user config's own; a parent's can
1150    /// only be removed from its file, with [`Self::save_subconfig`].
1151    pub fn clear_resources(&mut self) {
1152        self.set_resources(None);
1153    }
1154
1155    /// Removes the last `user-data=` definition, which lets an earlier one, such as a parent's,
1156    /// take effect. [`Self::save_user`] persists removing the user config's own; a parent's can
1157    /// only be removed from its file, with [`Self::save_subconfig`].
1158    pub fn clear_user_data(&mut self) {
1159        self.set_userdata(None);
1160    }
1161
1162    /// The file `save_user` writes: the user config directory's `openmw.cfg`.
1163    fn user_cfg_file(&self) -> PathBuf {
1164        self.user_config_path().join("openmw.cfg")
1165    }
1166
1167    /// True when `setting` comes from a file other than `cfg`, such as a parent of the user
1168    /// config. The injected data directories (no source file) are nobody's.
1169    fn is_inherited(setting: &SettingValue, cfg: &Path) -> bool {
1170        let source = setting.meta().source_config();
1171        !source.as_os_str().is_empty() && !util::paths_equivalent(source, cfg)
1172    }
1173
1174    /// Records that the user config has to replace `list` when saved (see `user_replaces`).
1175    fn replace_in_user_config(&mut self, list: ListOption) {
1176        if !self.user_replaces.contains(&list) {
1177            self.user_replaces.push(list);
1178        }
1179    }
1180
1181    /// Whether a parent of the chain contributes an entry of `list` that `select` accepts.
1182    fn parent_holds(&self, list: &ListOption, select: impl Fn(&SettingValue) -> bool) -> bool {
1183        let user_cfg = self.user_cfg_file();
1184        self.settings.iter().any(|setting| {
1185            list.holds(setting) && select(setting) && Self::is_inherited(setting, &user_cfg)
1186        })
1187    }
1188
1189    /// Replaces every entry of `list` with `entries`. When a parent contributed to it, the user
1190    /// config replaces the list when saved, so its parents' entries stay out on reload.
1191    fn replace_list(&mut self, list: ListOption, entries: impl IntoIterator<Item = SettingValue>) {
1192        let inherited = self.parent_holds(&list, |_| true);
1193        self.clear_matching_internal(|setting| list.holds(setting));
1194        if inherited {
1195            self.replace_in_user_config(list);
1196        }
1197        self.settings.extend(entries);
1198        self.rebuild_indexes();
1199    }
1200
1201    /// Removes the entries of `list` that `remove` accepts. When one was a parent's, the user
1202    /// config replaces the list when saved, with the entries that remain, so the removal
1203    /// persists without writing the parent's file; the remaining entries stay the parents'.
1204    fn remove_from_list(&mut self, list: ListOption, remove: impl Fn(&SettingValue) -> bool) {
1205        let inherited = self.parent_holds(&list, &remove);
1206        self.clear_matching_internal(|setting| list.holds(setting) && remove(setting));
1207        if inherited {
1208            self.replace_in_user_config(list);
1209        }
1210        self.rebuild_indexes();
1211    }
1212
1213    /// Content files are the actual *mods* or plugins which are created by either `OpenCS` or Bethesda's construction set
1214    /// These entries only refer to the names and ordering of content files.
1215    /// vfstool-lib should be used to derive paths
1216    pub fn content_files_iter(&self) -> impl Iterator<Item = &FileSetting> {
1217        self.files_at(&self.lists.content)
1218    }
1219
1220    /// Returns `true` if the named plugin is present in the `content=` list.
1221    #[must_use]
1222    pub fn has_content_file(&self, file_name: &str) -> bool {
1223        self.indexed_content.contains(file_name)
1224    }
1225
1226    /// Returns `true` if the named plugin is present in the `groundcover=` list.
1227    #[must_use]
1228    pub fn has_groundcover_file(&self, file_name: &str) -> bool {
1229        self.indexed_groundcover.contains(file_name)
1230    }
1231
1232    /// Returns `true` if the named archive is present in the `fallback-archive=` list.
1233    #[must_use]
1234    pub fn has_archive_file(&self, file_name: &str) -> bool {
1235        self.indexed_archives.contains(file_name)
1236    }
1237
1238    /// Returns `true` if the given path is present in the `data=` list.
1239    ///
1240    /// Both `/` and `\` are normalised to the platform separator before comparison,
1241    /// so the query does not need to use a specific separator style.
1242    #[must_use]
1243    pub fn has_data_dir(&self, file_name: &str) -> bool {
1244        let foreign = if std::path::MAIN_SEPARATOR == '/' {
1245            '\\'
1246        } else {
1247            '/'
1248        };
1249        if file_name.contains(foreign) {
1250            let query = file_name.replace(foreign, std::path::MAIN_SEPARATOR_STR);
1251            return self.indexed_data_dirs.contains(Path::new(&query));
1252        }
1253        self.indexed_data_dirs.contains(Path::new(file_name))
1254    }
1255
1256    /// # Errors
1257    /// Returns [`ConfigError::CannotAddContentFile`] if the file is already present in the config.
1258    pub fn add_content_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
1259        let duplicate = self.settings.iter().find_map(|setting| match setting {
1260            SettingValue::ContentFile(plugin) => {
1261                if plugin.value() == content_file {
1262                    Some(plugin)
1263                } else {
1264                    None
1265                }
1266            }
1267            _ => None,
1268        });
1269
1270        if let Some(duplicate) = duplicate {
1271            bail_config!(
1272                content_already_defined,
1273                duplicate.value().to_owned(),
1274                duplicate.meta().source_config
1275            )
1276        }
1277
1278        self.settings
1279            .push(SettingValue::ContentFile(FileSetting::new(
1280                content_file,
1281                &self.user_config_path().join("openmw.cfg"),
1282                &mut String::default(),
1283            )));
1284        self.rebuild_indexes();
1285
1286        Ok(())
1287    }
1288
1289    /// Iterates all `groundcover=` entries in definition order.
1290    pub fn groundcover_iter(&self) -> impl Iterator<Item = &FileSetting> {
1291        self.files_at(&self.lists.groundcover)
1292    }
1293
1294    /// # Errors
1295    /// Returns [`ConfigError::CannotAddGroundcoverFile`] if the file is already present in the config.
1296    pub fn add_groundcover_file(&mut self, content_file: &str) -> Result<(), ConfigError> {
1297        let duplicate = self.settings.iter().find_map(|setting| match setting {
1298            SettingValue::Groundcover(plugin) => {
1299                if plugin.value() == content_file {
1300                    Some(plugin)
1301                } else {
1302                    None
1303                }
1304            }
1305            _ => None,
1306        });
1307
1308        if let Some(duplicate) = duplicate {
1309            bail_config!(
1310                groundcover_already_defined,
1311                duplicate.value().to_owned(),
1312                duplicate.meta().source_config
1313            )
1314        }
1315
1316        self.settings
1317            .push(SettingValue::Groundcover(FileSetting::new(
1318                content_file,
1319                &self.user_config_path().join("openmw.cfg"),
1320                &mut String::default(),
1321            )));
1322        self.rebuild_indexes();
1323
1324        Ok(())
1325    }
1326
1327    /// Removes all `content=` entries matching `file_name`.
1328    ///
1329    /// Removing an entry a parent config defined makes the user config replace the content
1330    /// list: `save_user` then writes `replace=content` and every remaining entry, the parents'
1331    /// included, so the removal survives a reload. The remaining entries stay attributed to
1332    /// the files that define them, so saving one of those with
1333    /// [`save_subconfig`](Self::save_subconfig) writes them back there.
1334    pub fn remove_content_file(&mut self, file_name: &str) {
1335        self.remove_from_list(ListOption::Content, |setting| match setting {
1336            SettingValue::ContentFile(existing_file) => existing_file == file_name,
1337            _ => false,
1338        });
1339    }
1340
1341    /// Removes all `groundcover=` entries matching `file_name`; removing a parent's makes the
1342    /// user config replace the list as in [`Self::remove_content_file`] (`replace=groundcover`).
1343    pub fn remove_groundcover_file(&mut self, file_name: &str) {
1344        self.remove_from_list(ListOption::Groundcover, |setting| match setting {
1345            SettingValue::Groundcover(existing_file) => existing_file == file_name,
1346            _ => false,
1347        });
1348    }
1349
1350    /// Removes all `fallback-archive=` entries matching `file_name`; removing a parent's makes
1351    /// the user config replace the list as in [`Self::remove_content_file`]
1352    /// (`replace=fallback-archive`).
1353    pub fn remove_archive_file(&mut self, file_name: &str) {
1354        self.remove_from_list(ListOption::FallbackArchive, |setting| match setting {
1355            SettingValue::BethArchive(existing_file) => existing_file == file_name,
1356            _ => false,
1357        });
1358    }
1359
1360    /// Removes any `data=` entry whose resolved path or original string matches `data_dir`;
1361    /// removing a parent's makes the user config replace the list as in
1362    /// [`Self::remove_content_file`] (`replace=data`). The `resources/vfs` and `data-local`
1363    /// directories are no `data=` entries: they go when their settings are cleared.
1364    pub fn remove_data_directory(&mut self, data_dir: &PathBuf) {
1365        self.remove_from_list(ListOption::Data, |setting| match setting {
1366            SettingValue::DataDirectory(existing_data_dir) => {
1367                existing_data_dir.parsed() == data_dir
1368                    || existing_data_dir.original() == data_dir.to_string_lossy().as_ref()
1369            }
1370            _ => false,
1371        });
1372    }
1373
1374    /// Appends a data directory entry attributed to the user config. Does not check for duplicates.
1375    pub fn add_data_directory(&mut self, dir: &Path) {
1376        self.settings
1377            .push(SettingValue::DataDirectory(DirectorySetting::new(
1378                dir.to_string_lossy(),
1379                self.user_config_path().join("openmw.cfg"),
1380                &mut String::default(),
1381            )));
1382        self.rebuild_indexes();
1383    }
1384
1385    /// # Errors
1386    /// Returns [`ConfigError::CannotAddArchiveFile`] if the archive is already present in the config.
1387    pub fn add_archive_file(&mut self, archive_file: &str) -> Result<(), ConfigError> {
1388        let duplicate = self.settings.iter().find_map(|setting| match setting {
1389            SettingValue::BethArchive(archive) => {
1390                if archive.value() == archive_file {
1391                    Some(archive)
1392                } else {
1393                    None
1394                }
1395            }
1396            _ => None,
1397        });
1398
1399        if let Some(duplicate) = duplicate {
1400            bail_config!(
1401                archive_already_defined,
1402                duplicate.value().to_owned(),
1403                duplicate.meta().source_config
1404            )
1405        }
1406
1407        self.settings
1408            .push(SettingValue::BethArchive(FileSetting::new(
1409                archive_file,
1410                &self.user_config_path().join("openmw.cfg"),
1411                &mut String::default(),
1412            )));
1413        self.rebuild_indexes();
1414
1415        Ok(())
1416    }
1417
1418    /// Iterates all `fallback-archive=` entries in definition order.
1419    pub fn fallback_archives_iter(&self) -> impl Iterator<Item = &FileSetting> {
1420        self.files_at(&self.lists.archives)
1421    }
1422
1423    /// Replaces all `content=` entries with `plugins`, or clears them if `None`.
1424    ///
1425    /// Entries are attributed to the user config path. No duplicate checking is performed.
1426    /// When a parent config defined any of the old entries, `save_user` writes a
1427    /// `replace=content` line ahead of the new ones, so a reload yields exactly this list.
1428    pub fn set_content_files(&mut self, plugins: Option<Vec<String>>) {
1429        let cfg_path = self.user_cfg_file();
1430        let mut empty = String::default();
1431        let entries: Vec<SettingValue> = plugins
1432            .unwrap_or_default()
1433            .iter()
1434            .map(|plugin| {
1435                SettingValue::ContentFile(FileSetting::new(plugin, &cfg_path, &mut empty))
1436            })
1437            .collect();
1438        self.replace_list(ListOption::Content, entries);
1439    }
1440
1441    /// Replaces all `fallback-archive=` entries with `archives`, or clears them if `None`.
1442    ///
1443    /// Entries are attributed to the user config path. No duplicate checking is performed.
1444    /// Parent-defined entries are replaced through `replace=fallback-archive` as in
1445    /// [`Self::set_content_files`].
1446    pub fn set_fallback_archives(&mut self, archives: Option<Vec<String>>) {
1447        let cfg_path = self.user_cfg_file();
1448        let mut empty = String::default();
1449        let entries: Vec<SettingValue> = archives
1450            .unwrap_or_default()
1451            .iter()
1452            .map(|archive| {
1453                SettingValue::BethArchive(FileSetting::new(archive, &cfg_path, &mut empty))
1454            })
1455            .collect();
1456        self.replace_list(ListOption::FallbackArchive, entries);
1457    }
1458
1459    /// Iterates all preserved generic `key=value` entries in definition order.
1460    pub fn generic_settings_iter(&self) -> impl Iterator<Item = &GenericSetting> {
1461        self.lists
1462            .generic
1463            .iter()
1464            .filter_map(move |&index| match &self.settings[index] {
1465                SettingValue::Generic(generic) => Some(generic),
1466                _ => None,
1467            })
1468    }
1469
1470    /// Replaces all preserved generic `key=value` entries with `values`, or clears them if `None`.
1471    ///
1472    /// Entries are attributed to the user config path. No duplicate checking is performed.
1473    /// Parent-defined entries are replaced through `replace=<key>` as in
1474    /// [`Self::set_content_files`]; loading honours `replace=` for any key.
1475    pub fn set_generic_settings(&mut self, key: &str, values: Option<Vec<String>>) {
1476        let cfg_path = self.user_cfg_file();
1477        let mut empty = String::default();
1478        let entries: Vec<SettingValue> = values
1479            .unwrap_or_default()
1480            .iter()
1481            .map(|value| {
1482                SettingValue::Generic(GenericSetting::new(key, value, &cfg_path, &mut empty))
1483            })
1484            .collect();
1485        self.replace_list(ListOption::Generic(key.to_owned()), entries);
1486    }
1487
1488    /// Appends a preserved generic `key=value` entry attributed to the user config.
1489    pub fn add_generic_setting(&mut self, key: &str, value: &str) {
1490        self.settings
1491            .push(SettingValue::Generic(GenericSetting::new(
1492                key,
1493                value,
1494                &self.user_config_path().join("openmw.cfg"),
1495                &mut String::default(),
1496            )));
1497        self.rebuild_indexes();
1498    }
1499
1500    /// Iterates all settings for which `predicate` returns `true`.
1501    pub fn settings_matching<'a, P>(
1502        &'a self,
1503        predicate: P,
1504    ) -> impl Iterator<Item = &'a SettingValue>
1505    where
1506        P: Fn(&SettingValue) -> bool + 'a,
1507    {
1508        self.settings.iter().filter(move |s| predicate(s))
1509    }
1510
1511    /// Removes all settings for which `predicate` returns `true`.
1512    fn clear_matching_internal<P>(&mut self, predicate: P)
1513    where
1514        P: Fn(&SettingValue) -> bool,
1515    {
1516        self.settings.retain(|s| !predicate(s));
1517    }
1518
1519    /// The `data=` entries loading injects for `data-local=` and `resources=`/vfs. They belong to
1520    /// the effective data directory list, but no file declares them, so they have no source config
1521    /// and serialization leaves them out.
1522    fn is_synthetic_data_directory(setting: &SettingValue) -> bool {
1523        matches!(
1524            setting,
1525            SettingValue::DataDirectory(data_dir) if data_dir.meta().source_config.as_os_str().is_empty()
1526        )
1527    }
1528
1529    /// Removes all settings for which `predicate` returns `true`.
1530    pub fn clear_matching<P>(&mut self, predicate: P)
1531    where
1532        P: Fn(&SettingValue) -> bool,
1533    {
1534        self.clear_matching_internal(predicate);
1535        self.rebuild_indexes();
1536    }
1537
1538    /// Replaces all `data=` entries with `dirs`, or clears them if `None`. The `resources/vfs`
1539    /// and `data-local` directories stay first and last, as they follow their own settings.
1540    ///
1541    /// Entries are attributed to the user config path. No duplicate checking is performed.
1542    /// Parent-defined entries are replaced through `replace=data` as in
1543    /// [`Self::set_content_files`].
1544    pub fn set_data_directories(&mut self, dirs: Option<Vec<PathBuf>>) {
1545        let cfg_path = self.user_cfg_file();
1546        let mut empty = String::default();
1547        let entries: Vec<SettingValue> = dirs
1548            .unwrap_or_default()
1549            .iter()
1550            .map(|dir| {
1551                SettingValue::DataDirectory(DirectorySetting::new(
1552                    dir.to_string_lossy(),
1553                    cfg_path.clone(),
1554                    &mut empty,
1555                ))
1556            })
1557            .collect();
1558        self.replace_list(ListOption::Data, entries);
1559    }
1560
1561    /// Given a string resembling a fallback= entry's value, as it would exist in openmw.cfg,
1562    /// Add it to the settings map.
1563    /// This process must be non-destructive
1564    ///
1565    /// `comment` is the text written above the entry, in `openmw.cfg` comment form: every line
1566    /// that is not blank and does not already start with `#` gets a `# ` prefix, and the whole
1567    /// comment ends with a newline, so `"my note"` serializes as `# my note\nfallback=...`. It is
1568    /// consumed (left empty), as the parser's queued comments are.
1569    ///
1570    /// # Errors
1571    /// Returns [`ConfigError`] if `base_value` cannot be parsed as a valid game setting.
1572    pub fn set_game_setting(
1573        &mut self,
1574        base_value: &str,
1575        config_path: Option<PathBuf>,
1576        comment: &mut String,
1577    ) -> Result<(), ConfigError> {
1578        *comment = normalize_comment(comment);
1579        let new_setting = GameSettingType::try_from((
1580            base_value.to_owned(),
1581            config_path.unwrap_or_else(|| self.user_config_path().join("openmw.cfg")),
1582            comment,
1583        ))?;
1584
1585        self.settings.push(SettingValue::GameSetting(new_setting));
1586        self.rebuild_indexes();
1587
1588        Ok(())
1589    }
1590
1591    /// Replaces all `fallback=` entries with `settings`, or clears them if `None`.
1592    ///
1593    /// Each string must be in `Key,Value` format — the same as it would appear after the `=` in
1594    /// an `openmw.cfg` `fallback=` line. Parent-defined entries are replaced through
1595    /// `replace=fallback` as in [`Self::set_content_files`].
1596    ///
1597    /// # Errors
1598    /// Returns [`ConfigError`] if any entry in `settings` cannot be parsed as a valid game
1599    /// setting; the old entries are then cleared and none of the new ones added.
1600    pub fn set_game_settings(&mut self, settings: Option<Vec<String>>) -> Result<(), ConfigError> {
1601        let cfg_path = self.user_cfg_file();
1602        let mut empty = String::default();
1603        let mut entries = Vec::new();
1604        let mut failure = None;
1605        for setting in settings.unwrap_or_default() {
1606            match GameSettingType::try_from((setting, cfg_path.clone(), &mut empty)) {
1607                Ok(parsed) => entries.push(SettingValue::GameSetting(parsed)),
1608                Err(error) => {
1609                    failure = Some(error);
1610                    entries.clear();
1611                    break;
1612                }
1613            }
1614        }
1615        self.replace_list(ListOption::Fallback, entries);
1616        failure.map_or(Ok(()), Err)
1617    }
1618
1619    /// Iterates the `config=` entries of the chain, file by file in the order the files loaded,
1620    /// each file's in its own order. An entry naming a directory without an `openmw.cfg` is one:
1621    /// `OpenMW` keeps that directory in the chain. The chain loads depth first, so the last entry
1622    /// here need not be the user's config: [`Self::user_config_path`] is.
1623    ///
1624    /// An entry naming a config a `replace=config` dropped is left out, so
1625    /// [`Self::save_subconfig`] cannot write over that file.
1626    pub fn sub_configs(&self) -> impl Iterator<Item = &DirectorySetting> {
1627        self.directories_at(&self.lists.sub_configs)
1628    }
1629
1630    /// Returns the observed configuration-chain traversal in the order the loader tried each
1631    /// file: the root, then depth first through the `config=` entries, as `OpenMW` walks them.
1632    ///
1633    /// Includes successfully loaded config files and `config=` targets that were skipped
1634    /// because no `openmw.cfg` exists in that directory.
1635    pub fn config_chain(&self) -> impl Iterator<Item = &ConfigChainEntry> {
1636        self.chain.iter()
1637    }
1638
1639    /// Fallback entries are k/v pairs baked into the value side of k/v pairs in `fallback=` entries of openmw.cfg.
1640    /// They are used to express settings which are defined in Morrowind.ini for things such as:
1641    /// weather, lighting behaviors, UI colors, and levelup messages.
1642    ///
1643    /// Returns each key exactly once — when a key appears multiple times in the config chain, the
1644    /// last-defined value wins. Keys come in reverse order of their effective definitions, most
1645    /// recently defined first, so the user config's settings lead: `A,1 B,2 C,3 A,4` yields
1646    /// `A=4`, `C=3`, `B=2`.
1647    ///
1648    /// # Example
1649    /// ```no_run
1650    /// use openmw_config::OpenMWConfiguration;
1651    /// let config = OpenMWConfiguration::new(None)?;
1652    /// for setting in config.game_settings() {
1653    ///     println!("{}={}", setting.key(), setting.value());
1654    /// }
1655    /// # Ok::<(), openmw_config::ConfigError>(())
1656    /// ```
1657    pub fn game_settings(&self) -> impl Iterator<Item = &GameSettingType> {
1658        self.ensure_game_setting_indexes();
1659        // The iterator keeps the index borrowed; every mutation takes `&mut self`, so nothing
1660        // can rebuild it underneath.
1661        let order = self.indexed_game_setting_order.borrow();
1662        (0..order.len()).filter_map(move |position| match &self.settings[order[position]] {
1663            SettingValue::GameSetting(setting) => Some(setting),
1664            _ => None,
1665        })
1666    }
1667
1668    /// Retrieves a gamesetting according to its name.
1669    /// This would be whatever text comes after the equals sign `=` and before the first comma `,`
1670    /// Case-sensitive!
1671    #[must_use]
1672    pub fn get_game_setting(&self, key: &str) -> Option<&GameSettingType> {
1673        self.ensure_game_setting_indexes();
1674        self.indexed_game_setting_last
1675            .borrow()
1676            .get(key)
1677            .and_then(|index| match &self.settings[*index] {
1678                SettingValue::GameSetting(setting) => Some(setting),
1679                _ => None,
1680            })
1681    }
1682
1683    /// Data directories are the bulk of an `OpenMW` Configuration's contents,
1684    /// Composing the list of files from which a VFS is constructed.
1685    /// For a VFS implementation, see: <https://github.com/magicaldave/vfstool/tree/main/vfstool_lib>
1686    ///
1687    /// Calling this function will give the post-parsed versions of directories defined by an openmw.cfg,
1688    /// So the real ones may easily be iterated and loaded.
1689    /// There is not actually validation anywhere in the crate that `DirectorySettings` refer to a directory which actually exists.
1690    /// This is according to the openmw.cfg specification and doesn't technically break anything but should be considered when using these paths.
1691    pub fn data_directories_iter(&self) -> impl Iterator<Item = &DirectorySetting> {
1692        self.directories_at(&self.lists.data_dirs)
1693    }
1694
1695    const MAX_CONFIG_DEPTH: usize = 16;
1696
1697    /// Loads the chain rooted at `root_config` as `OpenMW`'s `ConfigurationManager` does: the
1698    /// configs in the order `readConfiguration` reads them, then merged as
1699    /// `mergeComposingVariables` merges them.
1700    fn load(&mut self, root_config: &Path) -> Result<(), ConfigError> {
1701        // The config= entries still to load, the next on top. readConfiguration walks them with
1702        // a stack, so a file's first config= and everything it names load before its second.
1703        let mut pending_configs = vec![(root_config.to_path_buf(), 0usize)];
1704        // Every directory the walk has tried, the root's first: readConfiguration skips a
1705        // config= directory it has already tried ("Repeated config dir"), so a chain that loops
1706        // or branches into the same directory twice reads each once. Paths compare as written
1707        // once resolved, not canonicalized.
1708        let mut tried_dirs: FxHashSet<PathBuf> = FxHashSet::default();
1709        // The configs the chain keeps, in load order: readConfiguration's parsedConfigs.
1710        let mut loaded: Vec<ConfigFile> = Vec::new();
1711        // The directories of the chain, in load order, those without an openmw.cfg included:
1712        // readConfiguration's mActiveConfigPaths, whose last is the user config directory.
1713        let mut active_dirs: Vec<PathBuf> = Vec::new();
1714
1715        while let Some((config_path, depth)) = pending_configs.pop() {
1716            let cfg_file_path = if config_path.is_dir() {
1717                config_path.join("openmw.cfg")
1718            } else {
1719                config_path
1720            };
1721            let config_dir = cfg_file_path
1722                .parent()
1723                .map(Path::to_path_buf)
1724                .unwrap_or_default();
1725            if !tried_dirs.insert(config_dir.clone()) {
1726                util::debug_log_lazy(|| {
1727                    format!(
1728                        "Skipping {} as the chain already tried it!",
1729                        cfg_file_path.display()
1730                    )
1731                });
1732                continue;
1733            }
1734
1735            if !cfg_file_path.exists() {
1736                if depth == 0 {
1737                    bail_config!(cannot_find, cfg_file_path);
1738                }
1739                util::debug_log_lazy(|| {
1740                    format!("Skipping {} as it does not exist!", cfg_file_path.display())
1741                });
1742                self.chain.push(ConfigChainEntry {
1743                    path: cfg_file_path,
1744                    depth,
1745                    status: ConfigChainStatus::SkippedMissing,
1746                });
1747                active_dirs.push(config_dir);
1748                continue;
1749            }
1750
1751            if depth > Self::MAX_CONFIG_DEPTH {
1752                bail_config!(max_depth_exceeded, cfg_file_path);
1753            }
1754
1755            util::debug_log_lazy(|| format!("BEGIN CONFIG PARSING: {}", cfg_file_path.display()));
1756
1757            self.chain.push(ConfigChainEntry {
1758                path: cfg_file_path.clone(),
1759                depth,
1760                status: ConfigChainStatus::Loaded,
1761            });
1762            let file = ConfigFile::read(&cfg_file_path)?;
1763
1764            // A config after the root that says replace=config drops the configs read before
1765            // it except the root, and the directories with them, once a config other than the
1766            // root has loaded. Every line of its own stays, and the walk carries on through
1767            // every config= entry still to load. In the root it does nothing.
1768            if loaded.len() > 1 && file.replaces.iter().any(|name| name == "config") {
1769                loaded.truncate(1);
1770                active_dirs.truncate(1);
1771            }
1772            // The first on top, as readConfiguration's addExtraConfigDirs pushes them.
1773            pending_configs.extend(
1774                file.sub_configs()
1775                    .rev()
1776                    .map(|dir| (dir.parsed().join("openmw.cfg"), depth + 1)),
1777            );
1778            active_dirs.push(config_dir);
1779            loaded.push(file);
1780        }
1781
1782        // readConfiguration ends by creating the user config directory, the last of the chain,
1783        // with std::filesystem::create_directories, which throws when it cannot: OpenMW stops
1784        // with a fatal error. It writes no openmw.cfg there.
1785        if let Some(user_dir) = active_dirs.last()
1786            && std::fs::create_dir_all(user_dir).is_err()
1787        {
1788            bail_config!(not_writable, user_dir);
1789        }
1790
1791        self.merge(loaded, &active_dirs)?;
1792        if active_dirs.len() > 1 {
1793            self.user_config_dir = active_dirs.pop();
1794        }
1795        self.rebuild_indexes();
1796
1797        Ok(())
1798    }
1799
1800    /// Adds the chain's configs to the settings in load order, merged as
1801    /// `mergeComposingVariables` merges them: from the last config to the first, an entry of a
1802    /// list stays unless a config after its own names that list in `replace=`, exactly as
1803    /// written. So a config's `replace=` discards what the configs before it said, and none of
1804    /// its own entries, wherever it sits. The `replace=` lines are a list too, as in `OpenMW`'s
1805    /// engine: a config's `replace=replace` discards the `replace=` lines of the configs before
1806    /// it, which then no longer reach the configs before those. Single values and comments are
1807    /// no lists; a `config=` entry stays while its directory is one of `active_dirs`, whether
1808    /// or not it holds an `openmw.cfg`.
1809    ///
1810    /// # Errors
1811    /// Fails on a `content=`, `groundcover=` or `fallback-archive=` name that stays twice, at
1812    /// the second, as `OpenMW` refuses a content file listed twice.
1813    fn merge(
1814        &mut self,
1815        loaded: Vec<ConfigFile>,
1816        active_dirs: &[PathBuf],
1817    ) -> Result<(), ConfigError> {
1818        let mut replaced: FxHashSet<String> = FxHashSet::default();
1819        let mut kept_per_config = Vec::with_capacity(loaded.len());
1820        for file in loaded.into_iter().rev() {
1821            let kept: Vec<(SettingValue, usize)> = file
1822                .settings
1823                .into_iter()
1824                .filter(|(setting, _)| match setting {
1825                    SettingValue::SubConfiguration(dir) => {
1826                        active_dirs.iter().any(|active| active == dir.parsed())
1827                    }
1828                    _ => list_name(setting).is_none_or(|list| !replaced.contains(list)),
1829                })
1830                .collect();
1831            // The replace= values the configs before this one merge under: its own, unless a
1832            // config after it discarded them with replace=replace.
1833            replaced.extend(kept.iter().filter_map(|(setting, _)| match setting {
1834                SettingValue::Replace(replace) => Some(replace.value().to_owned()),
1835                _ => None,
1836            }));
1837            kept_per_config.push(kept);
1838        }
1839
1840        let mut seen_content = FxHashSet::default();
1841        let mut seen_groundcover = FxHashSet::default();
1842        let mut seen_archives = FxHashSet::default();
1843        for (setting, line) in kept_per_config.into_iter().rev().flatten() {
1844            match &setting {
1845                SettingValue::ContentFile(file) if !seen_content.insert(file.value().clone()) => {
1846                    bail_config!(
1847                        duplicate_content_file,
1848                        file.value().clone(),
1849                        file.meta().source_config(),
1850                        line
1851                    );
1852                }
1853                SettingValue::Groundcover(file)
1854                    if !seen_groundcover.insert(file.value().clone()) =>
1855                {
1856                    bail_config!(
1857                        duplicate_groundcover_file,
1858                        file.value().clone(),
1859                        file.meta().source_config(),
1860                        line
1861                    );
1862                }
1863                SettingValue::BethArchive(file) if !seen_archives.insert(file.value().clone()) => {
1864                    bail_config!(
1865                        duplicate_archive_file,
1866                        file.value().clone(),
1867                        file.meta().source_config(),
1868                        line
1869                    );
1870                }
1871                _ => {}
1872            }
1873            self.settings.push(setting);
1874        }
1875
1876        Ok(())
1877    }
1878
1879    fn write_config(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1880        use std::io::Write;
1881        use std::time::{SystemTime, UNIX_EPOCH};
1882
1883        let parent = path
1884            .parent()
1885            .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1886
1887        let nonce = SystemTime::now()
1888            .duration_since(UNIX_EPOCH)
1889            .map_or(0, |d| d.as_nanos());
1890        let tmp_path = parent.join(format!(
1891            ".openmw-config-tmp-{}-{}",
1892            std::process::id(),
1893            nonce
1894        ));
1895
1896        let mut file = std::fs::OpenOptions::new()
1897            .write(true)
1898            .create_new(true)
1899            .open(&tmp_path)?;
1900
1901        file.write_all(config_string.as_bytes())?;
1902        file.sync_all()?;
1903
1904        #[cfg(windows)]
1905        // The lint guards against making files world-writable on Unix; this block only
1906        // clears the Windows read-only attribute so the destination can be replaced.
1907        #[allow(clippy::permissions_set_readonly_false)]
1908        {
1909            if path.exists() {
1910                if let Ok(metadata) = std::fs::metadata(path) {
1911                    let mut permissions = metadata.permissions();
1912                    if permissions.readonly() {
1913                        // Windows blocks deletion of read-only files, and the atomic replace path
1914                        // needs the destination gone before we rename the temp file into place.
1915                        permissions.set_readonly(false);
1916                        std::fs::set_permissions(path, permissions)?;
1917                    }
1918                }
1919
1920                std::fs::remove_file(path)?;
1921            }
1922        }
1923
1924        std::fs::rename(&tmp_path, path)?;
1925
1926        Ok(())
1927    }
1928
1929    fn save_string_to_path(config_string: &str, path: &Path) -> Result<(), ConfigError> {
1930        let parent = path
1931            .parent()
1932            .ok_or_else(|| ConfigError::NotWritable(path.to_path_buf()))?;
1933        let writable_probe = parent.join(".openmw-config-write-test");
1934
1935        if !util::is_writable(&writable_probe) {
1936            bail_config!(not_writable, parent);
1937        }
1938
1939        Self::write_config(config_string, path)
1940    }
1941
1942    /// Serializes the fully-composed configuration as a relocated, flattened `openmw.cfg`.
1943    ///
1944    /// Directory-valued settings (`data=`, `data-local=`, `resources=`, and `user-data=`) are
1945    /// emitted from their resolved paths, not their original token or relative spelling. Chain
1946    /// control entries such as `config=` and `replace=` are excluded because this output is already
1947    /// the composed result; reloading the chain from the flattened file would be doing the work
1948    /// twice, and probably differently. The `data=` entries loading injects for `data-local=` and
1949    /// `resources=` are also omitted.
1950    ///
1951    /// Use the [`Display`] implementation for preservation-oriented serialization instead.
1952    #[must_use]
1953    pub fn to_resolved_string(&self) -> String {
1954        let mut config_string = String::new();
1955
1956        for setting in &self.settings {
1957            if Self::is_synthetic_data_directory(setting) {
1958                continue;
1959            }
1960
1961            if let Some(setting_string) = setting.to_resolved_string() {
1962                config_string.push_str(&setting_string);
1963            }
1964        }
1965
1966        // Writing to a String cannot fail.
1967        let _ = write_serializer_stamp(&mut config_string);
1968
1969        config_string
1970    }
1971
1972    /// Writes the full composite configuration to an arbitrary path.
1973    ///
1974    /// This is intended for importer-style output where the exact destination is supplied by the
1975    /// caller rather than inferred from the loaded config chain.
1976    ///
1977    /// # Errors
1978    /// Returns [`ConfigError::NotWritable`] if the destination directory is not writable.
1979    /// Returns [`ConfigError::Io`] if writing the file fails.
1980    pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1981        Self::save_string_to_path(&self.to_string(), path.as_ref())
1982    }
1983
1984    /// Writes [`Self::to_resolved_string`] to an arbitrary path using atomic replace semantics.
1985    ///
1986    /// This is the relocation-safe export API for importers and preview/output tools. It omits
1987    /// `config=` and `replace=` chain-control entries and writes directory-valued settings using
1988    /// resolved paths so moving the output file does not change what those paths mean.
1989    ///
1990    /// # Errors
1991    /// Returns [`ConfigError::NotWritable`] if the destination directory is not writable.
1992    /// Returns [`ConfigError::Io`] if writing the file fails.
1993    pub fn save_resolved_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
1994        Self::save_string_to_path(&self.to_resolved_string(), path.as_ref())
1995    }
1996
1997    /// The text of `cfg_path`, one file of the chain: the settings attributed to it, in order,
1998    /// with their comments, and the comments that ended it still at the end, after any setting
1999    /// added since. In the user config, each list in `user_replaces` is written behind a
2000    /// `replace=` line, ahead of the list's first entry in the file or at its end, followed by
2001    /// the entries the parents still contribute to that list.
2002    fn file_text(&self, cfg_path: &Path) -> String {
2003        let mut pending: Vec<&ListOption> =
2004            if util::paths_equivalent(cfg_path, &self.user_cfg_file()) {
2005                self.user_replaces.iter().collect()
2006            } else {
2007                Vec::new()
2008            };
2009        let mut text = String::new();
2010        let mut trailing = String::new();
2011
2012        for setting in self.settings.iter().filter(|setting| {
2013            !Self::is_synthetic_data_directory(setting)
2014                && util::paths_equivalent(setting.meta().source_config(), cfg_path)
2015        }) {
2016            if let SettingValue::TrailingComment(end) = setting {
2017                trailing.push_str(&end.to_string());
2018                continue;
2019            }
2020            // A replace= line the file already has serves; the parents' entries follow it.
2021            if let SettingValue::Replace(replace) = setting
2022                && let Some(index) = pending.iter().position(|list| list.is_replaced_by(replace))
2023            {
2024                text.push_str(&setting.to_string());
2025                self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
2026                continue;
2027            }
2028            if let Some(index) = pending.iter().position(|list| list.holds(setting)) {
2029                Self::write_replace_line(&mut text, pending[index]);
2030                self.write_inherited_entries(&mut text, pending.remove(index), cfg_path);
2031            }
2032            text.push_str(&setting.to_string());
2033        }
2034
2035        for list in pending {
2036            Self::write_replace_line(&mut text, list);
2037            self.write_inherited_entries(&mut text, list, cfg_path);
2038        }
2039
2040        text.push_str(&trailing);
2041        text
2042    }
2043
2044    fn write_replace_line(text: &mut String, list: &ListOption) {
2045        text.push_str("replace=");
2046        text.push_str(list.name());
2047        text.push('\n');
2048    }
2049
2050    /// Writes the entries of `list` that files other than `cfg_path` define, as lines of
2051    /// `cfg_path`. Their comments stay in their own files, and a data directory whose text
2052    /// would resolve to another directory from `cfg_path`, a relative path, is written resolved.
2053    fn write_inherited_entries(&self, text: &mut String, list: &ListOption, cfg_path: &Path) {
2054        for setting in self
2055            .settings
2056            .iter()
2057            .filter(|setting| list.holds(setting) && Self::is_inherited(setting, cfg_path))
2058        {
2059            let mut entry = setting.clone();
2060            entry.meta_mut().comment.clear();
2061            if let SettingValue::DataDirectory(dir) = &entry {
2062                let original_here = DirectorySetting::new(
2063                    dir.original(),
2064                    cfg_path.to_path_buf(),
2065                    &mut String::new(),
2066                );
2067                if original_here.parsed() != dir.parsed() {
2068                    entry = SettingValue::DataDirectory(DirectorySetting::new(
2069                        dir.parsed().to_string_lossy(),
2070                        cfg_path.to_path_buf(),
2071                        &mut String::new(),
2072                    ));
2073                }
2074            }
2075            text.push_str(&entry.to_string());
2076        }
2077    }
2078
2079    /// Saves the currently-defined user openmw.cfg configuration.
2080    ///
2081    /// Only settings whose source is the user config file are written; parent configs are never
2082    /// touched. When a `set_*` or `remove_*` method replaced a list a parent contributed to, or
2083    /// removed a parent's entry from one, the user config replaces that list: a `replace=` line
2084    /// followed by the entries the parents still contribute, then its own. So what this writes
2085    /// loads back as what was in memory.
2086    ///
2087    /// On a fresh install the user's `openmw.cfg` does not exist yet; this writes it, as
2088    /// `OpenMW`'s launcher does when it first saves, and creates its directory again if it has
2089    /// gone since loading created it.
2090    ///
2091    /// # Errors
2092    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
2093    /// Returns [`ConfigError::Io`] if writing the file fails.
2094    pub fn save_user(&self) -> Result<(), ConfigError> {
2095        let target_dir = self.user_config_path();
2096        let cfg_path = target_dir.join("openmw.cfg");
2097
2098        std::fs::create_dir_all(&target_dir)?;
2099
2100        if !util::is_writable(&cfg_path) {
2101            bail_config!(not_writable, &cfg_path);
2102        }
2103
2104        Self::write_config(&self.file_text(&cfg_path), &cfg_path)
2105    }
2106
2107    /// Saves the openmw.cfg belonging to a loaded sub-configuration.
2108    ///
2109    /// `target_dir` must be the directory of a `config=` entry already present in the loaded
2110    /// chain. This method refuses to write to arbitrary paths to prevent accidental overwrites.
2111    /// The file gets the settings attributed to it, as [`Self::save_user`] writes the user's.
2112    ///
2113    /// # Errors
2114    /// Returns [`ConfigError::SubconfigNotLoaded`] if `target_dir` is not part of the chain.
2115    /// Returns [`ConfigError::NotWritable`] if the target path is not writable.
2116    /// Returns [`ConfigError::Io`] if writing the file fails.
2117    pub fn save_subconfig(&self, target_dir: &Path) -> Result<(), ConfigError> {
2118        let subconfig_is_loaded = self.settings.iter().any(|setting| match setting {
2119            SettingValue::SubConfiguration(subconfig) => {
2120                util::paths_equivalent(subconfig.parsed(), target_dir)
2121                    || subconfig.original() == target_dir.to_string_lossy().as_ref()
2122            }
2123            _ => false,
2124        });
2125
2126        if !subconfig_is_loaded {
2127            bail_config!(subconfig_not_loaded, target_dir);
2128        }
2129
2130        let cfg_path = target_dir.join("openmw.cfg");
2131
2132        if !util::is_writable(&cfg_path) {
2133            bail_config!(not_writable, &cfg_path);
2134        }
2135
2136        Self::write_config(&self.file_text(&cfg_path), &cfg_path)
2137    }
2138}
2139
2140/// Preservation-oriented `openmw.cfg` serialization for the composed configuration.
2141///
2142/// Directory-valued settings use their original text so user-authored relative paths, tokens, and
2143/// quoting survive round-trips. That makes this suitable for preservation-style output and for the
2144/// internals of [`OpenMWConfiguration::save_user`] / [`OpenMWConfiguration::save_subconfig`]. It is
2145/// not relocation-safe flattened export: writing this string to a different directory can change the
2146/// meaning of relative paths. Use [`OpenMWConfiguration::to_resolved_string`] or
2147/// [`OpenMWConfiguration::save_resolved_to_path`] for importer/export output.
2148///
2149/// The `data=` entries loading injects for `data-local=` and `resources=` are omitted. Comments and
2150/// `replace=` entries are preserved. The comments that ended a file stay after that file's
2151/// settings, including those added since loading.
2152impl fmt::Display for OpenMWConfiguration {
2153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2154        let mut trailing: Vec<&SettingValue> = Vec::new();
2155
2156        for setting in self
2157            .settings
2158            .iter()
2159            .filter(|setting| !Self::is_synthetic_data_directory(setting))
2160        {
2161            if let SettingValue::TrailingComment(_) = setting {
2162                trailing.push(setting);
2163                continue;
2164            }
2165            if trailing
2166                .first()
2167                .is_some_and(|end| end.meta().source_config() != setting.meta().source_config())
2168            {
2169                trailing.drain(..).try_for_each(|end| write!(f, "{end}"))?;
2170            }
2171            write!(f, "{setting}")?;
2172        }
2173        trailing
2174            .into_iter()
2175            .try_for_each(|end| write!(f, "{end}"))?;
2176
2177        write_serializer_stamp(f)?;
2178
2179        Ok(())
2180    }
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185    use super::*;
2186    use std::io::Write;
2187    use std::sync::atomic::{AtomicU64, Ordering};
2188
2189    // -----------------------------------------------------------------------
2190    // Helpers
2191    // -----------------------------------------------------------------------
2192
2193    fn write_cfg(dir: &std::path::Path, contents: &str) -> PathBuf {
2194        let cfg = dir.join("openmw.cfg");
2195        let mut f = std::fs::File::create(&cfg).unwrap();
2196        f.write_all(contents.as_bytes()).unwrap();
2197        cfg
2198    }
2199
2200    fn temp_dir() -> PathBuf {
2201        // Use a per-process atomic counter so concurrent tests always get distinct
2202        // directories.  The old `subsec_nanos()` approach could collide when two
2203        // tests ran at the same nanosecond offset in different seconds, causing
2204        // one to overwrite the other's openmw.cfg before it was read.
2205        static COUNTER: AtomicU64 = AtomicU64::new(0);
2206        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
2207        let base =
2208            std::env::temp_dir().join(format!("openmw_cfg_test_{}_{id}", std::process::id()));
2209        std::fs::create_dir_all(&base).unwrap();
2210        base
2211    }
2212
2213    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2214        crate::test_env_lock()
2215    }
2216
2217    unsafe fn clear_from_env_overrides() {
2218        // SAFETY: callers hold env_lock(). Rust 2024 made this explicit because environment
2219        // mutation is process-global. Quite right, annoyingly.
2220        unsafe {
2221            std::env::remove_var("OPENMW_CONFIG");
2222            std::env::remove_var("OPENMW_CONFIG_DIR");
2223            std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
2224            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
2225            std::env::remove_var("OPENMW_FLATPAK_ID");
2226            std::env::remove_var("FLATPAK_ID");
2227        }
2228    }
2229
2230    #[cfg(not(windows))]
2231    fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
2232        // SAFETY: callers hold env_lock(). Environment mutation is process-global.
2233        unsafe {
2234            if let Some(value) = value {
2235                std::env::set_var(key, value);
2236            } else {
2237                std::env::remove_var(key);
2238            }
2239        }
2240    }
2241
2242    fn load(cfg_contents: &str) -> OpenMWConfiguration {
2243        let dir = temp_dir();
2244        write_cfg(&dir, cfg_contents);
2245        OpenMWConfiguration::new(Some(dir)).unwrap()
2246    }
2247
2248    #[cfg(unix)]
2249    fn symlink_dir(target: &std::path::Path, link: &std::path::Path) {
2250        std::os::unix::fs::symlink(target, link).unwrap();
2251    }
2252
2253    // -----------------------------------------------------------------------
2254    // Content files
2255    // -----------------------------------------------------------------------
2256
2257    #[test]
2258    fn test_content_files_empty_on_bare_config() {
2259        let config = load("");
2260        assert!(config.content_files_iter().next().is_none());
2261    }
2262
2263    #[test]
2264    fn test_content_files_parsed_in_order() {
2265        let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\ncontent=Bloodmoon.esm\n");
2266        let files: Vec<&String> = config
2267            .content_files_iter()
2268            .map(FileSetting::value)
2269            .collect();
2270        assert_eq!(
2271            files,
2272            vec!["Morrowind.esm", "Tribunal.esm", "Bloodmoon.esm"]
2273        );
2274    }
2275
2276    #[test]
2277    fn test_has_content_file_found() {
2278        let config = load("content=Morrowind.esm\n");
2279        assert!(config.has_content_file("Morrowind.esm"));
2280    }
2281
2282    #[test]
2283    fn test_has_content_file_not_found() {
2284        let config = load("content=Morrowind.esm\n");
2285        assert!(!config.has_content_file("Tribunal.esm"));
2286    }
2287
2288    #[test]
2289    fn test_duplicate_content_file_errors_on_load() {
2290        let dir = temp_dir();
2291        write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
2292        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2293    }
2294
2295    #[test]
2296    fn test_duplicate_content_file_error_reports_line_number() {
2297        let dir = temp_dir();
2298        write_cfg(&dir, "content=Morrowind.esm\ncontent=Morrowind.esm\n");
2299
2300        let result = OpenMWConfiguration::new(Some(dir));
2301        assert!(matches!(
2302            result,
2303            Err(ConfigError::DuplicateContentFile { line: Some(2), .. })
2304        ));
2305    }
2306
2307    #[test]
2308    fn test_add_content_file_appends() {
2309        let mut config = load("content=Morrowind.esm\n");
2310        config.add_content_file("MyMod.esp").unwrap();
2311        assert!(config.has_content_file("MyMod.esp"));
2312    }
2313
2314    #[test]
2315    fn test_add_duplicate_content_file_errors() {
2316        let mut config = load("content=Morrowind.esm\n");
2317        assert!(config.add_content_file("Morrowind.esm").is_err());
2318    }
2319
2320    #[test]
2321    fn test_add_content_file_source_config_is_cfg_file() {
2322        let dir = temp_dir();
2323        let cfg_path = write_cfg(&dir, "");
2324        let mut config = OpenMWConfiguration::new(Some(dir)).unwrap();
2325        config.add_content_file("Mod.esp").unwrap();
2326        let setting = config.content_files_iter().next().unwrap();
2327        assert_eq!(
2328            setting.meta().source_config,
2329            cfg_path,
2330            "source_config should be the openmw.cfg file, not a directory"
2331        );
2332    }
2333
2334    #[test]
2335    fn test_remove_content_file() {
2336        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2337        config.remove_content_file("Morrowind.esm");
2338        assert!(!config.has_content_file("Morrowind.esm"));
2339        assert!(config.has_content_file("Tribunal.esm"));
2340    }
2341
2342    #[test]
2343    fn test_set_content_files_replaces_all() {
2344        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2345        config.set_content_files(Some(vec!["NewMod.esp".to_string()]));
2346        assert!(!config.has_content_file("Morrowind.esm"));
2347        assert!(!config.has_content_file("Tribunal.esm"));
2348        assert!(config.has_content_file("NewMod.esp"));
2349    }
2350
2351    #[test]
2352    fn test_set_content_files_none_clears_all() {
2353        let mut config = load("content=Morrowind.esm\n");
2354        config.set_content_files(None);
2355        assert!(config.content_files_iter().next().is_none());
2356    }
2357
2358    // -----------------------------------------------------------------------
2359    // Fallback archives
2360    // -----------------------------------------------------------------------
2361
2362    #[test]
2363    fn test_fallback_archives_parsed() {
2364        let config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
2365        let archives: Vec<&String> = config
2366            .fallback_archives_iter()
2367            .map(FileSetting::value)
2368            .collect();
2369        assert_eq!(archives, vec!["Morrowind.bsa", "Tribunal.bsa"]);
2370    }
2371
2372    #[test]
2373    fn test_has_archive_file() {
2374        let config = load("fallback-archive=Morrowind.bsa\n");
2375        assert!(config.has_archive_file("Morrowind.bsa"));
2376        assert!(!config.has_archive_file("Tribunal.bsa"));
2377    }
2378
2379    #[test]
2380    fn test_set_game_setting_comment_is_written_as_a_comment_line() {
2381        let mut config = load("content=Morrowind.esm\n");
2382        let mut comment = String::from("my note");
2383        config
2384            .set_game_setting("fJumpHeight,1.0", None, &mut comment)
2385            .unwrap();
2386        assert!(comment.is_empty(), "the comment is consumed");
2387        assert!(
2388            config
2389                .to_string()
2390                .contains("# my note\nfallback=fJumpHeight,1.0\n"),
2391            "{config}"
2392        );
2393
2394        let mut multi = String::from("# already a comment\n\nsecond line");
2395        config
2396            .set_game_setting("iLevel,2", None, &mut multi)
2397            .unwrap();
2398        assert!(
2399            config
2400                .to_string()
2401                .contains("# already a comment\n\n# second line\nfallback=iLevel,2\n"),
2402            "{config}"
2403        );
2404
2405        let mut empty = String::new();
2406        config
2407            .set_game_setting("iOther,3", None, &mut empty)
2408            .unwrap();
2409        assert!(config.to_string().contains("\nfallback=iOther,3\n"));
2410        assert_eq!(normalize_comment("# kept\n"), "# kept\n");
2411        assert_eq!(normalize_comment("\n"), "\n");
2412    }
2413
2414    #[test]
2415    fn test_trailing_comments_survive_serialization() {
2416        let config = load("# head\ncontent=A.esp\n\n# tail\n");
2417        let serialized = config.to_string();
2418        assert!(
2419            serialized.starts_with(
2420                "# head\ncontent=A.esp\n\n# tail\n# OpenMW-Config Serializer Version:"
2421            ),
2422            "{serialized}"
2423        );
2424
2425        // The stamp never accumulates and the trailer is stable across reloads.
2426        let reloaded = load(&serialized).to_string();
2427        assert_eq!(reloaded, serialized);
2428        assert_eq!(serialized.matches(SERIALIZER_STAMP_PREFIX).count(), 1);
2429
2430        // A file that ends right after its last setting has no trailer.
2431        let plain = load("content=A.esp\n").to_string();
2432        assert!(
2433            plain.starts_with("content=A.esp\n# OpenMW-Config"),
2434            "{plain}"
2435        );
2436
2437        // A file of only comments keeps them.
2438        let only = load("# nothing here\n").to_string();
2439        assert!(
2440            only.starts_with("# nothing here\n# OpenMW-Config"),
2441            "{only}"
2442        );
2443    }
2444
2445    #[test]
2446    fn test_singleton_changes_keep_the_game_setting_index_valid() {
2447        // The lookup index holds positions; removing the encoding entry ahead of the game
2448        // settings shifts them, so the index must follow.
2449        let mut config =
2450            load("encoding=win1252\nfallback=iFirst,1\nfallback=iSecond,2\nno-sound=1\n");
2451        assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
2452        config.set_encoding(None);
2453        assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
2454        assert_eq!(config.get_game_setting("iSecond").unwrap().value(), "2");
2455        let keys: Vec<_> = config
2456            .game_settings()
2457            .map(GameSettingType::key_str)
2458            .collect();
2459        assert_eq!(keys, ["iSecond", "iFirst"]);
2460
2461        config.set_user_data_path("/tmp/user");
2462        config.clear_user_data();
2463        assert_eq!(config.get_game_setting("iFirst").unwrap().value(), "1");
2464    }
2465
2466    #[test]
2467    fn test_injected_data_directories_are_indexed() {
2468        let dir = temp_dir();
2469        let resources = dir.join("res");
2470        write_cfg(
2471            &dir,
2472            &format!("resources={}\ndata=Data Files\n", resources.display()),
2473        );
2474        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2475        assert!(config.has_data_dir(&resources.join("vfs").to_string_lossy()));
2476        assert!(config.has_data_dir(&dir.join("Data Files").to_string_lossy()));
2477    }
2478
2479    #[test]
2480    fn test_list_counts_follow_the_lists() {
2481        let mut config = load(
2482            "content=A.esm\ncontent=B.esm\ngroundcover=G.esp\nfallback-archive=X.bsa\ndata=Data Files\nfallback=iA,1\nfallback=iA,2\nfallback=iB,3\nfoo=1\nfoo=2\n",
2483        );
2484        assert_eq!(config.content_file_count(), 2);
2485        assert_eq!(config.groundcover_file_count(), 1);
2486        assert_eq!(config.archive_file_count(), 1);
2487        assert_eq!(config.data_directory_count(), 1);
2488        assert_eq!(config.sub_config_count(), 0);
2489        assert_eq!(config.game_setting_count(), 2, "distinct keys");
2490        assert_eq!(config.generic_setting_count(), 2);
2491        config.remove_content_file("A.esm");
2492        config.set_generic_settings("foo", None);
2493        assert_eq!(config.content_file_count(), 1);
2494        assert_eq!(config.generic_setting_count(), 0);
2495        assert_eq!(config.content_files_iter().count(), 1);
2496    }
2497
2498    #[test]
2499    fn test_add_duplicate_archive_errors() {
2500        let mut config = load("fallback-archive=Morrowind.bsa\n");
2501        let result = config.add_archive_file("Morrowind.bsa");
2502        assert!(
2503            matches!(result, Err(ConfigError::CannotAddArchiveFile { ref file, .. }) if file == "Morrowind.bsa"),
2504            "{result:?}"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_duplicate_archive_error_reports_line_number() {
2510        let dir = temp_dir();
2511        write_cfg(
2512            &dir,
2513            "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
2514        );
2515
2516        let result = OpenMWConfiguration::new(Some(dir));
2517        assert!(matches!(
2518            result,
2519            Err(ConfigError::DuplicateArchiveFile { line: Some(2), .. })
2520        ));
2521    }
2522
2523    #[test]
2524    fn test_remove_archive_file() {
2525        let mut config = load("fallback-archive=Morrowind.bsa\nfallback-archive=Tribunal.bsa\n");
2526        config.remove_archive_file("Morrowind.bsa");
2527        assert!(!config.has_archive_file("Morrowind.bsa"));
2528        assert!(config.has_archive_file("Tribunal.bsa"));
2529    }
2530
2531    // -----------------------------------------------------------------------
2532    // Groundcover
2533    // -----------------------------------------------------------------------
2534
2535    #[test]
2536    fn test_groundcover_parsed() {
2537        let config = load("groundcover=GrassPlugin.esp\n");
2538        let grass: Vec<&String> = config.groundcover_iter().map(FileSetting::value).collect();
2539        assert_eq!(grass, vec!["GrassPlugin.esp"]);
2540    }
2541
2542    #[test]
2543    fn test_has_groundcover_file() {
2544        let config = load("groundcover=Grass.esp\n");
2545        assert!(config.has_groundcover_file("Grass.esp"));
2546        assert!(!config.has_groundcover_file("Other.esp"));
2547    }
2548
2549    #[test]
2550    fn test_duplicate_groundcover_errors_on_load() {
2551        let dir = temp_dir();
2552        write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
2553        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2554    }
2555
2556    #[test]
2557    fn test_duplicate_groundcover_error_reports_line_number() {
2558        let dir = temp_dir();
2559        write_cfg(&dir, "groundcover=Grass.esp\ngroundcover=Grass.esp\n");
2560
2561        let result = OpenMWConfiguration::new(Some(dir));
2562        assert!(matches!(
2563            result,
2564            Err(ConfigError::DuplicateGroundcoverFile { line: Some(2), .. })
2565        ));
2566    }
2567
2568    // -----------------------------------------------------------------------
2569    // Data directories
2570    // -----------------------------------------------------------------------
2571
2572    #[test]
2573    fn test_data_directories_absolute_paths_parsed() {
2574        let config = load("data=/absolute/path/to/data\n");
2575        assert!(
2576            config
2577                .data_directories_iter()
2578                .any(|d| d.parsed().ends_with("absolute/path/to/data"))
2579        );
2580    }
2581
2582    #[test]
2583    fn test_add_data_directory() {
2584        let mut config = load("");
2585        config.add_data_directory(Path::new("/some/data/dir"));
2586        assert!(config.has_data_dir("/some/data/dir"));
2587    }
2588
2589    #[test]
2590    fn test_set_data_directories_replaces_all() {
2591        let mut config = load("data=/old/dir\n");
2592        config.set_data_directories(Some(vec![PathBuf::from("/new/dir")]));
2593        assert!(!config.has_data_dir("/old/dir"));
2594        assert!(config.has_data_dir("/new/dir"));
2595    }
2596
2597    #[test]
2598    fn test_remove_data_directory() {
2599        let mut config = load("data=/keep/me\n");
2600        config.add_data_directory(Path::new("/remove/me"));
2601        config.remove_data_directory(&PathBuf::from("/remove/me"));
2602        assert!(!config.has_data_dir("/remove/me"));
2603        assert!(config.has_data_dir("/keep/me"));
2604    }
2605
2606    // -----------------------------------------------------------------------
2607    // Fallback (game) settings
2608    // -----------------------------------------------------------------------
2609
2610    #[test]
2611    fn test_game_settings_parsed() {
2612        let config = load("fallback=iMaxLevel,100\n");
2613        let setting = config.get_game_setting("iMaxLevel").unwrap();
2614        assert_eq!(setting.value(), "100");
2615    }
2616
2617    #[test]
2618    fn test_game_settings_last_wins() {
2619        let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2620        let setting = config.get_game_setting("iKey").unwrap();
2621        assert_eq!(setting.value(), "2");
2622    }
2623
2624    #[test]
2625    fn test_game_settings_deduplicates_by_key() {
2626        // When the same fallback key appears more than once, game_settings() must emit only the
2627        // last-defined value (last-wins), matching the behavior of get_game_setting().
2628        let config = load("fallback=iKey,1\nfallback=iKey,2\n");
2629        let results: Vec<_> = config
2630            .game_settings()
2631            .filter(|s| s.key() == "iKey")
2632            .collect();
2633        assert_eq!(
2634            results.len(),
2635            1,
2636            "game_settings() should deduplicate by key"
2637        );
2638        assert_eq!(results[0].value(), "2", "last-defined value should win");
2639    }
2640
2641    #[test]
2642    fn test_get_game_setting_missing_returns_none() {
2643        let config = load("fallback=iKey,1\n");
2644        assert!(config.get_game_setting("iMissing").is_none());
2645    }
2646
2647    #[test]
2648    fn test_game_setting_color_roundtrip() {
2649        let config = load("fallback=iSkyColor,100,149,237\n");
2650        let setting = config.get_game_setting("iSkyColor").unwrap();
2651        assert_eq!(setting.value(), "100,149,237");
2652    }
2653
2654    #[test]
2655    fn test_game_setting_float_roundtrip() {
2656        let config = load("fallback=fGravity,9.81\n");
2657        let setting = config.get_game_setting("fGravity").unwrap();
2658        assert_eq!(setting.value(), "9.81");
2659    }
2660
2661    #[test]
2662    fn test_invalid_game_setting_error_reports_line_number() {
2663        let dir = temp_dir();
2664        write_cfg(&dir, "fallback=iGood,1\nfallback=InvalidEntry\n");
2665
2666        let result = OpenMWConfiguration::new(Some(dir));
2667        assert!(matches!(
2668            result,
2669            Err(ConfigError::InvalidGameSetting { line: Some(2), .. })
2670        ));
2671    }
2672
2673    // -----------------------------------------------------------------------
2674    // Encoding
2675    // -----------------------------------------------------------------------
2676
2677    #[test]
2678    fn test_encoding_parsed() {
2679        use crate::config::encodingsetting::EncodingType;
2680        let config = load("encoding=win1252\n");
2681        assert_eq!(config.encoding().unwrap().value(), EncodingType::WIN1252);
2682    }
2683
2684    #[test]
2685    fn test_invalid_encoding_errors_on_load() {
2686        let dir = temp_dir();
2687        write_cfg(&dir, "encoding=utf8\n");
2688        assert!(OpenMWConfiguration::new(Some(dir)).is_err());
2689    }
2690
2691    #[test]
2692    fn test_invalid_encoding_error_reports_line_number() {
2693        let dir = temp_dir();
2694        write_cfg(&dir, "content=Morrowind.esm\nencoding=utf8\n");
2695
2696        let result = OpenMWConfiguration::new(Some(dir));
2697        assert!(matches!(
2698            result,
2699            Err(ConfigError::BadEncoding { line: Some(2), .. })
2700        ));
2701    }
2702
2703    // -----------------------------------------------------------------------
2704    // Replace semantics
2705    // -----------------------------------------------------------------------
2706
2707    /// A root holding `root` that chains to a user config holding `user`.
2708    fn load_chain(root: &str, user: &str) -> OpenMWConfiguration {
2709        let root_dir = temp_dir();
2710        let user_dir = temp_dir();
2711        write_cfg(&user_dir, user);
2712        write_cfg(&root_dir, &format!("{root}config={}\n", user_dir.display()));
2713        OpenMWConfiguration::new(Some(root_dir)).unwrap()
2714    }
2715
2716    #[test]
2717    fn test_replace_content_clears_prior_plugins() {
2718        let config = load_chain("content=Old.esm\n", "replace=content\ncontent=New.esm\n");
2719        assert!(!config.has_content_file("Old.esm"));
2720        assert!(config.has_content_file("New.esm"));
2721    }
2722
2723    #[test]
2724    fn test_replace_content_keeps_the_files_own_plugins() {
2725        // mergeComposingVariables applies a file's replace= to the files before it only.
2726        let config = load("content=Old.esm\nreplace=content\ncontent=New.esm\n");
2727        assert!(config.has_content_file("Old.esm"));
2728        assert!(config.has_content_file("New.esm"));
2729    }
2730
2731    #[test]
2732    fn test_replace_data_clears_prior_dirs() {
2733        let config = load_chain("data=/old\n", "data=/own\nreplace=data\ndata=/new\n");
2734        assert!(!config.has_data_dir("/old"));
2735        assert!(config.has_data_dir("/own"));
2736        assert!(config.has_data_dir("/new"));
2737    }
2738
2739    #[test]
2740    fn test_replace_keeps_comment_adjacency() {
2741        let config = load_chain(
2742            "content=Old.esm\n",
2743            "replace=content\n\n# keep me\ncontent=New.esm\n",
2744        );
2745        let output = config.to_string();
2746
2747        assert!(!output.contains("Old.esm"));
2748        assert!(output.contains("# keep me\ncontent=New.esm"));
2749    }
2750
2751    // -----------------------------------------------------------------------
2752    // Display / serialisation
2753    // -----------------------------------------------------------------------
2754
2755    #[test]
2756    fn test_display_contains_version_comment() {
2757        let config = load("content=Morrowind.esm\n");
2758        let output = config.to_string();
2759        assert!(
2760            output.contains("# OpenMW-Config Serializer Version:"),
2761            "Display should include version comment"
2762        );
2763    }
2764
2765    #[test]
2766    fn test_display_preserves_content_entries() {
2767        let config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
2768        let output = config.to_string();
2769        assert!(output.contains("content=Morrowind.esm"));
2770        assert!(output.contains("content=Tribunal.esm"));
2771    }
2772
2773    #[test]
2774    fn test_display_preserves_comments() {
2775        let config = load("# This is a comment\ncontent=Morrowind.esm\n");
2776        let output = config.to_string();
2777        assert!(output.contains("# This is a comment"));
2778    }
2779
2780    // -----------------------------------------------------------------------
2781    // Generic settings
2782    // -----------------------------------------------------------------------
2783
2784    #[test]
2785    fn test_generic_setting_preserved() {
2786        let config = load("some-unknown-key=some-value\n");
2787        let output = config.to_string();
2788        assert!(output.contains("some-unknown-key=some-value"));
2789    }
2790
2791    #[test]
2792    fn test_generic_settings_can_be_replaced_and_iterated() {
2793        let mut config = load("no-sound=1\nno-sound=0\nother=keep\n");
2794
2795        config.set_generic_settings("no-sound", Some(vec!["2".to_string()]));
2796
2797        let values: Vec<(&str, &str)> = config
2798            .generic_settings_iter()
2799            .map(|setting| (setting.key(), setting.value()))
2800            .collect();
2801
2802        assert_eq!(values, vec![("other", "keep"), ("no-sound", "2")]);
2803    }
2804
2805    #[test]
2806    fn test_save_to_path_writes_exact_output_path() {
2807        let dir = temp_dir();
2808        write_cfg(&dir, "no-sound=1\ncontent=Morrowind.esm\n");
2809        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2810
2811        let out = temp_dir().join("imported-openmw.cfg");
2812        config.save_to_path(&out).unwrap();
2813
2814        let saved = std::fs::read_to_string(&out).unwrap();
2815        assert!(saved.contains("no-sound=1"));
2816        assert!(saved.contains("content=Morrowind.esm"));
2817    }
2818
2819    #[test]
2820    fn test_save_to_path_overwrites_read_only_existing_file() {
2821        #[cfg(unix)]
2822        {
2823            use std::os::unix::fs::PermissionsExt;
2824
2825            let dir = temp_dir();
2826            write_cfg(&dir, "no-sound=1\n");
2827            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2828
2829            let out = dir.join("export.cfg");
2830            std::fs::write(&out, "old=content\n").unwrap();
2831            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o444)).unwrap();
2832
2833            config.save_to_path(&out).unwrap();
2834
2835            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o644)).unwrap();
2836            let saved = std::fs::read_to_string(&out).unwrap();
2837            assert!(saved.contains("no-sound=1"));
2838            assert!(!saved.contains("old=content"));
2839        }
2840    }
2841
2842    #[test]
2843    fn test_save_to_path_rejects_unwritable_parent_directory() {
2844        #[cfg(unix)]
2845        {
2846            use std::os::unix::fs::PermissionsExt;
2847
2848            let dir = temp_dir();
2849            write_cfg(&dir, "no-sound=1\n");
2850            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2851
2852            let parent = temp_dir();
2853            std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
2854
2855            let out = parent.join("export.cfg");
2856            let result = config.save_to_path(&out);
2857
2858            std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
2859
2860            assert!(matches!(result, Err(ConfigError::NotWritable(_))));
2861        }
2862    }
2863
2864    // -----------------------------------------------------------------------
2865    // save_user
2866    // -----------------------------------------------------------------------
2867
2868    #[test]
2869    fn test_save_user_round_trips_content_files() {
2870        let dir = temp_dir();
2871        write_cfg(&dir, "content=Morrowind.esm\ncontent=Tribunal.esm\n");
2872        let mut config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2873        config.add_content_file("Bloodmoon.esm").unwrap();
2874        config.save_user().unwrap();
2875
2876        let reloaded = OpenMWConfiguration::new(Some(dir)).unwrap();
2877        let files: Vec<&String> = reloaded
2878            .content_files_iter()
2879            .map(FileSetting::value)
2880            .collect();
2881        assert!(files.contains(&&"Morrowind.esm".to_string()));
2882        assert!(files.contains(&&"Bloodmoon.esm".to_string()));
2883    }
2884
2885    #[test]
2886    fn test_save_user_does_not_persist_injected_data_local_as_data() {
2887        let dir = temp_dir();
2888        let data_local = dir.join("data-local");
2889        write_cfg(
2890            &dir,
2891            &format!(
2892                "data-local={}\ndata={}\n",
2893                data_local.display(),
2894                data_local.display()
2895            ),
2896        );
2897
2898        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2899        assert!(config.has_data_dir(&data_local.to_string_lossy()));
2900
2901        config.save_user().unwrap();
2902
2903        let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
2904        assert_eq!(saved.matches("data-local=").count(), 1);
2905        assert_eq!(saved.matches("data=").count(), 1);
2906    }
2907
2908    #[test]
2909    fn test_composite_display_does_not_serialize_injected_data_local_as_data() {
2910        let dir = temp_dir();
2911        let data_local = dir.join("data-local");
2912        write_cfg(&dir, &format!("data-local={}\n", data_local.display()));
2913
2914        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2915        let serialized = config.to_string();
2916
2917        assert!(serialized.contains("data-local="));
2918        assert!(!serialized.contains("data="));
2919    }
2920
2921    #[test]
2922    fn test_display_preserves_directory_originals_for_round_trip_boundary() {
2923        let dir = temp_dir();
2924        write_cfg(
2925            &dir,
2926            "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\n",
2927        );
2928
2929        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
2930        let serialized = config.to_string();
2931
2932        assert!(serialized.contains("data=Data Files"));
2933        assert!(serialized.contains("data-local=\"Local Data\""));
2934        assert!(serialized.contains("resources=?local?/resources"));
2935        assert!(serialized.contains("user-data=?userdata?"));
2936    }
2937
2938    #[test]
2939    fn test_to_resolved_string_emits_resolved_directory_paths() {
2940        let dir = temp_dir();
2941        write_cfg(
2942            &dir,
2943            "data=Data Files\ndata-local=\"Local Data\"\nresources=?local?/resources\nuser-data=?userdata?\ncontent=Base.esm\nfallback-archive=Morrowind.bsa\n",
2944        );
2945
2946        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2947        let serialized = config.to_resolved_string();
2948
2949        assert!(!serialized.contains("data=Data Files"));
2950        assert!(!serialized.contains("data-local=\"Local Data\""));
2951        assert!(!serialized.contains("resources=?local?/resources"));
2952        assert!(!serialized.contains("userdata="));
2953        assert!(serialized.contains(&format!("data={}", dir.join("Data Files").display())));
2954        assert!(serialized.contains(&format!("data-local={}", dir.join("Local Data").display())));
2955        assert!(serialized.contains("user-data="));
2956        assert!(serialized.contains("content=Base.esm"));
2957        assert!(serialized.contains("fallback-archive=Morrowind.bsa"));
2958    }
2959
2960    #[test]
2961    fn test_to_resolved_string_excludes_config_replace_and_synthetic_data_local() {
2962        let root = temp_dir();
2963        let sub = temp_dir();
2964        let data_local = root.join("local-data");
2965        write_cfg(
2966            &root,
2967            &format!(
2968                "data-local={}\nconfig={}\ncontent=Root.esm\n",
2969                data_local.display(),
2970                sub.display()
2971            ),
2972        );
2973        write_cfg(&sub, "content=Sub.esm\n");
2974
2975        let mut config = OpenMWConfiguration::new(Some(root)).unwrap();
2976        config.add_generic_setting("replace", "data");
2977
2978        let serialized = config.to_resolved_string();
2979
2980        assert!(!serialized.contains("config="));
2981        assert!(!serialized.contains("replace="));
2982        assert_eq!(serialized.matches("data-local=").count(), 1);
2983        assert_eq!(serialized.matches("data=").count(), 0);
2984        assert!(serialized.contains("content=Root.esm"));
2985        assert!(serialized.contains("content=Sub.esm"));
2986    }
2987
2988    #[test]
2989    fn test_save_resolved_to_path_writes_relocated_safe_output() {
2990        let dir = temp_dir();
2991        let out = temp_dir().join("openmw.cfg");
2992        write_cfg(&dir, "data=Data Files\ncontent=Base.esm\n");
2993
2994        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
2995        config.save_resolved_to_path(&out).unwrap();
2996
2997        let saved = std::fs::read_to_string(out).unwrap();
2998        assert!(saved.contains(&format!("data={}", dir.join("Data Files").display())));
2999        assert!(!saved.contains("data=Data Files"));
3000        assert!(saved.contains("content=Base.esm"));
3001    }
3002
3003    #[test]
3004    fn test_save_user_not_writable_returns_error() {
3005        // Only meaningful on Unix — skip on other platforms
3006        #[cfg(unix)]
3007        {
3008            use std::os::unix::fs::PermissionsExt;
3009            let dir = temp_dir();
3010            write_cfg(&dir, "content=Morrowind.esm\n");
3011            let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3012
3013            // Make the directory read-only so we can't write openmw.cfg
3014            let cfg_path = dir.join("openmw.cfg");
3015            std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o444)).unwrap();
3016
3017            let result = config.save_user();
3018            // Restore permissions before asserting so temp cleanup works
3019            std::fs::set_permissions(&cfg_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3020
3021            assert!(
3022                matches!(result, Err(ConfigError::NotWritable(_))),
3023                "expected NotWritable, got {result:?}"
3024            );
3025        }
3026    }
3027
3028    // -----------------------------------------------------------------------
3029    // save_subconfig
3030    // -----------------------------------------------------------------------
3031
3032    #[test]
3033    fn test_save_subconfig_rejects_unloaded_path() {
3034        let dir = temp_dir();
3035        write_cfg(&dir, "content=Morrowind.esm\n");
3036        let config = OpenMWConfiguration::new(Some(dir)).unwrap();
3037
3038        let fake_dir = temp_dir();
3039        let result = config.save_subconfig(&fake_dir);
3040        assert!(
3041            matches!(result, Err(ConfigError::SubconfigNotLoaded(_))),
3042            "expected SubconfigNotLoaded, got {result:?}"
3043        );
3044    }
3045
3046    #[test]
3047    fn test_save_subconfig_round_trips_settings() {
3048        let root_dir = temp_dir();
3049        let sub_dir = temp_dir();
3050        write_cfg(&sub_dir, "content=Plugin.esp\n");
3051        write_cfg(
3052            &root_dir,
3053            &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3054        );
3055
3056        let mut config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3057        config.add_content_file("NewPlugin.esp").unwrap();
3058        config.save_subconfig(&sub_dir).unwrap();
3059
3060        let sub_cfg = sub_dir.join("openmw.cfg");
3061        let saved = std::fs::read_to_string(sub_cfg).unwrap();
3062        assert!(
3063            saved.contains("content=Plugin.esp"),
3064            "sub-config content preserved"
3065        );
3066    }
3067
3068    #[test]
3069    #[cfg(unix)]
3070    fn test_save_subconfig_accepts_equivalent_symlink_path() {
3071        let root_dir = temp_dir();
3072        let real_sub_dir = temp_dir();
3073        let linked_sub_dir = temp_dir().join("linked_subconfig");
3074
3075        std::os::unix::fs::symlink(&real_sub_dir, &linked_sub_dir).unwrap();
3076        write_cfg(&real_sub_dir, "content=Plugin.esp\n");
3077        write_cfg(
3078            &root_dir,
3079            &format!(
3080                "content=Morrowind.esm\nconfig={}\n",
3081                linked_sub_dir.display()
3082            ),
3083        );
3084
3085        let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3086        config.save_subconfig(&real_sub_dir).unwrap();
3087
3088        let saved = std::fs::read_to_string(real_sub_dir.join("openmw.cfg")).unwrap();
3089        assert!(
3090            saved.contains("content=Plugin.esp"),
3091            "sub-config settings should match by filesystem identity, not symlink spelling"
3092        );
3093    }
3094
3095    // -----------------------------------------------------------------------
3096    // Empty and optional construction
3097    // -----------------------------------------------------------------------
3098
3099    #[test]
3100    fn test_new_empty_uses_directory_as_user_config_context() {
3101        let dir = temp_dir().join("not-created-yet");
3102
3103        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3104        config.add_content_file("EmptyStart.esp").unwrap();
3105        config.add_data_directory(Path::new("Data Files"));
3106        config
3107            .set_game_setting("fScale,1.0", None, &mut String::new())
3108            .unwrap();
3109
3110        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3111        assert_eq!(config.root_config_dir(), dir);
3112        assert_eq!(config.user_config_path(), config.root_config_dir());
3113
3114        let content = config.content_files_iter().next().unwrap();
3115        assert_eq!(content.meta().source_config, config.root_config_file());
3116
3117        let data = config.data_directories_iter().next().unwrap();
3118        assert_eq!(data.meta().source_config, config.root_config_file());
3119        assert_eq!(data.parsed(), &config.root_config_dir().join("Data Files"));
3120
3121        let game_setting = config.get_game_setting("fScale").unwrap();
3122        assert_eq!(game_setting.meta().source_config, config.root_config_file());
3123    }
3124
3125    #[test]
3126    fn test_new_empty_rejects_openmw_cfg_file_shaped_path() {
3127        let result = OpenMWConfiguration::new_empty(temp_dir().join("openmw.cfg"));
3128        assert!(matches!(result, Err(ConfigError::NotFileOrDirectory(_))));
3129    }
3130
3131    #[test]
3132    fn test_save_to_path_works_from_new_empty_config() {
3133        let dir = temp_dir().join("missing-config-dir");
3134        let out_dir = temp_dir();
3135        let out = out_dir.join("openmw.cfg");
3136
3137        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3138        config.add_content_file("Generated.esp").unwrap();
3139        config.save_to_path(&out).unwrap();
3140
3141        let saved = std::fs::read_to_string(out).unwrap();
3142        assert!(saved.contains("content=Generated.esp"));
3143    }
3144
3145    #[test]
3146    fn test_save_user_creates_missing_new_empty_config_directory() {
3147        let dir = temp_dir().join("missing-user-config-dir");
3148
3149        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3150        config.add_content_file("Generated.esp").unwrap();
3151        config.save_user().unwrap();
3152
3153        let saved = std::fs::read_to_string(dir.join("openmw.cfg")).unwrap();
3154        assert!(saved.contains("content=Generated.esp"));
3155    }
3156
3157    #[test]
3158    fn test_load_optional_existing_cfg_matches_new() {
3159        let dir = temp_dir();
3160        let cfg = write_cfg(&dir, "content=Existing.esm\ndata=Data Files\n");
3161
3162        let from_new = OpenMWConfiguration::new(Some(cfg.clone())).unwrap();
3163        let from_optional = OpenMWConfiguration::load_optional(&cfg).unwrap();
3164
3165        assert_eq!(
3166            from_optional.root_config_file(),
3167            from_new.root_config_file()
3168        );
3169        assert_eq!(from_optional.to_string(), from_new.to_string());
3170    }
3171
3172    #[test]
3173    fn test_load_optional_missing_cfg_uses_parent_context() {
3174        let dir = temp_dir().join("cfg-parent");
3175        let cfg = dir.join("openmw.cfg");
3176
3177        let mut config = OpenMWConfiguration::load_optional(&cfg).unwrap();
3178        config.add_data_directory(Path::new("Data Files"));
3179
3180        assert_eq!(config.root_config_file(), cfg);
3181        assert_eq!(config.root_config_dir(), dir);
3182        assert_eq!(
3183            config.data_directories_iter().next().unwrap().parsed(),
3184            &config.root_config_dir().join("Data Files")
3185        );
3186    }
3187
3188    #[test]
3189    fn test_load_optional_existing_empty_dir_uses_dir_context() {
3190        let dir = temp_dir();
3191
3192        let mut config = OpenMWConfiguration::load_optional(&dir).unwrap();
3193        config.add_data_directory(Path::new("Data Files"));
3194
3195        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3196        assert_eq!(config.root_config_dir(), dir);
3197        assert_eq!(
3198            config.data_directories_iter().next().unwrap().parsed(),
3199            &config.root_config_dir().join("Data Files")
3200        );
3201    }
3202
3203    #[test]
3204    fn test_load_optional_missing_dir_uses_dir_context() {
3205        let dir = temp_dir().join("missing-dir");
3206
3207        let config = OpenMWConfiguration::load_optional(&dir).unwrap();
3208
3209        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3210        assert_eq!(config.root_config_dir(), dir);
3211    }
3212
3213    #[test]
3214    fn test_new_empty_relative_dir_anchors_to_current_dir_for_resolved_export() {
3215        let relative_dir = PathBuf::from(format!(
3216            "openmw-config-relative-empty-{}",
3217            std::process::id()
3218        ));
3219        let expected_dir = util::display_preserving_absolute(&relative_dir).unwrap();
3220
3221        let mut config = OpenMWConfiguration::new_empty(&relative_dir).unwrap();
3222        config.add_data_directory(Path::new("Data Files"));
3223
3224        assert_eq!(config.root_config_dir(), expected_dir);
3225        assert!(config.to_resolved_string().contains(&format!(
3226            "data={}",
3227            expected_dir.join("Data Files").display()
3228        )));
3229    }
3230
3231    #[test]
3232    fn test_load_optional_relative_missing_cfg_anchors_to_current_dir() {
3233        let relative_cfg = PathBuf::from(format!(
3234            "openmw-config-relative-missing-{}/openmw.cfg",
3235            std::process::id()
3236        ));
3237        let expected_dir =
3238            util::display_preserving_absolute(relative_cfg.parent().unwrap()).unwrap();
3239
3240        let mut config = OpenMWConfiguration::load_optional(&relative_cfg).unwrap();
3241        config.add_data_directory(Path::new("Data Files"));
3242
3243        assert_eq!(config.root_config_dir(), expected_dir);
3244        assert!(config.to_resolved_string().contains(&format!(
3245            "data={}",
3246            expected_dir.join("Data Files").display()
3247        )));
3248    }
3249
3250    #[test]
3251    fn test_path_level_directory_singleton_setters_use_user_config_context() {
3252        let dir = temp_dir();
3253        let mut config = OpenMWConfiguration::new_empty(&dir).unwrap();
3254
3255        config.set_data_local_path("local-data");
3256        config.set_resources_path("resources-dir");
3257        config.set_user_data_path("user-data-dir");
3258
3259        assert_eq!(
3260            config.data_local().unwrap().meta().source_config,
3261            config.root_config_file()
3262        );
3263        assert_eq!(
3264            config.resources().unwrap().meta().source_config,
3265            config.root_config_file()
3266        );
3267        assert_eq!(
3268            config.userdata().unwrap().meta().source_config,
3269            config.root_config_file()
3270        );
3271        assert_eq!(
3272            config.data_local().unwrap().parsed(),
3273            &dir.join("local-data")
3274        );
3275        assert_eq!(
3276            config.resources().unwrap().parsed(),
3277            &dir.join("resources-dir")
3278        );
3279        assert_eq!(
3280            config.userdata().unwrap().parsed(),
3281            &dir.join("user-data-dir")
3282        );
3283
3284        let serialized = config.to_string();
3285        assert!(serialized.contains("data-local=local-data"));
3286        assert!(serialized.contains("resources=resources-dir"));
3287        assert!(serialized.contains("user-data=user-data-dir"));
3288        assert!(!serialized.contains("userdata="));
3289    }
3290
3291    #[test]
3292    fn test_repeated_path_level_singleton_setters_replace() {
3293        let dir = temp_dir();
3294        let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
3295
3296        config.set_data_local_path("first-local");
3297        config.set_data_local_path("second-local");
3298        config.set_resources_path("first-resources");
3299        config.set_resources_path("second-resources");
3300        config.set_user_data_path("first-user-data");
3301        config.set_user_data_path("second-user-data");
3302
3303        let serialized = config.to_string();
3304        assert_eq!(serialized.matches("data-local=").count(), 1);
3305        assert_eq!(serialized.matches("resources=").count(), 1);
3306        assert_eq!(serialized.matches("user-data=").count(), 1);
3307        assert!(serialized.contains("data-local=second-local"));
3308        assert!(serialized.contains("resources=second-resources"));
3309        assert!(serialized.contains("user-data=second-user-data"));
3310    }
3311
3312    #[test]
3313    fn test_path_level_directory_singleton_clearers_remove_values() {
3314        let dir = temp_dir();
3315        let mut config = OpenMWConfiguration::new_empty(dir).unwrap();
3316
3317        config.set_data_local_path("local-data");
3318        config.set_resources_path("resources-dir");
3319        config.set_user_data_path("user-data-dir");
3320
3321        config.clear_data_local();
3322        config.clear_resources();
3323        config.clear_user_data();
3324
3325        assert!(config.data_local().is_none());
3326        assert!(config.resources().is_none());
3327        assert!(config.userdata().is_none());
3328    }
3329
3330    // -----------------------------------------------------------------------
3331    // from_env
3332    // -----------------------------------------------------------------------
3333
3334    #[test]
3335    fn test_from_env_openmw_config_dir() {
3336        let _guard = env_lock();
3337        let dir = temp_dir();
3338        write_cfg(&dir, "content=Morrowind.esm\n");
3339
3340        // SAFETY: tests that mutate env must not run concurrently with each other.
3341        // The test binary is single-threaded by default so this is acceptable.
3342        unsafe {
3343            clear_from_env_overrides();
3344            std::env::set_var("OPENMW_CONFIG_DIR", &dir);
3345        }
3346        let config = OpenMWConfiguration::from_env().unwrap();
3347        unsafe { clear_from_env_overrides() };
3348
3349        assert!(config.has_content_file("Morrowind.esm"));
3350        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3351    }
3352
3353    #[test]
3354    fn test_from_env_openmw_config_file() {
3355        let _guard = env_lock();
3356        let dir = temp_dir();
3357        let cfg = write_cfg(&dir, "content=Tribunal.esm\n");
3358
3359        unsafe {
3360            clear_from_env_overrides();
3361            std::env::set_var("OPENMW_CONFIG", &cfg);
3362        }
3363        let config = OpenMWConfiguration::from_env().unwrap();
3364        unsafe { clear_from_env_overrides() };
3365
3366        assert!(config.has_content_file("Tribunal.esm"));
3367        assert_eq!(config.root_config_file(), cfg);
3368    }
3369
3370    #[test]
3371    fn test_from_env_prefers_openmw_config_file_over_dir() {
3372        let _guard = env_lock();
3373        let file_dir = temp_dir();
3374        let dir_dir = temp_dir();
3375        let cfg = write_cfg(&file_dir, "content=FileWins.esm\n");
3376        write_cfg(&dir_dir, "content=DirLoses.esm\n");
3377
3378        unsafe {
3379            clear_from_env_overrides();
3380            std::env::set_var("OPENMW_CONFIG", &cfg);
3381            std::env::set_var("OPENMW_CONFIG_DIR", &dir_dir);
3382        }
3383
3384        let config = OpenMWConfiguration::from_env().unwrap();
3385        unsafe { clear_from_env_overrides() };
3386
3387        assert_eq!(config.root_config_file(), cfg);
3388        assert!(config.has_content_file("FileWins.esm"));
3389        assert!(!config.has_content_file("DirLoses.esm"));
3390    }
3391
3392    #[test]
3393    fn test_from_env_prefers_first_openmw_config_dir_entry() {
3394        let _guard = env_lock();
3395        let missing_dir = temp_dir().join("missing");
3396        let first_valid = temp_dir();
3397        let second_valid = temp_dir();
3398        write_cfg(&first_valid, "content=FirstValid.esm\n");
3399        write_cfg(&second_valid, "content=SecondValid.esm\n");
3400
3401        let separator = if cfg!(windows) { ";" } else { ":" };
3402        let path_list = format!(
3403            "{}{}{}{}{}",
3404            missing_dir.display(),
3405            separator,
3406            first_valid.display(),
3407            separator,
3408            second_valid.display()
3409        );
3410
3411        unsafe {
3412            clear_from_env_overrides();
3413            std::env::set_var("OPENMW_CONFIG_DIR", path_list);
3414        }
3415
3416        let config = OpenMWConfiguration::from_env().unwrap();
3417        unsafe { clear_from_env_overrides() };
3418
3419        assert_eq!(config.root_config_file(), first_valid.join("openmw.cfg"));
3420        assert!(config.has_content_file("FirstValid.esm"));
3421        assert!(!config.has_content_file("SecondValid.esm"));
3422    }
3423
3424    #[test]
3425    fn test_from_env_config_dir_skips_non_file_openmw_cfg_entry() {
3426        let _guard = env_lock();
3427        let invalid_dir = temp_dir();
3428        let valid_dir = temp_dir();
3429        std::fs::create_dir_all(invalid_dir.join("openmw.cfg")).unwrap();
3430        write_cfg(&valid_dir, "content=ActualFile.esm\n");
3431
3432        let separator = if cfg!(windows) { ";" } else { ":" };
3433        let path_list = format!(
3434            "{}{}{}",
3435            invalid_dir.display(),
3436            separator,
3437            valid_dir.display()
3438        );
3439
3440        unsafe {
3441            clear_from_env_overrides();
3442            std::env::set_var("OPENMW_CONFIG_DIR", path_list);
3443        }
3444
3445        let config = OpenMWConfiguration::from_env().unwrap();
3446        unsafe { clear_from_env_overrides() };
3447
3448        assert_eq!(config.root_config_file(), valid_dir.join("openmw.cfg"));
3449        assert!(config.has_content_file("ActualFile.esm"));
3450    }
3451
3452    #[test]
3453    fn test_from_env_uses_global_config_when_no_explicit_env() {
3454        let _guard = env_lock();
3455        let global_dir = temp_dir();
3456        let cfg = write_cfg(&global_dir, "content=GlobalRoot.esm\n");
3457
3458        unsafe {
3459            clear_from_env_overrides();
3460            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
3461        }
3462
3463        let config = OpenMWConfiguration::from_env().unwrap();
3464        unsafe { clear_from_env_overrides() };
3465
3466        assert_eq!(config.root_config_file(), cfg);
3467        assert!(config.has_content_file("GlobalRoot.esm"));
3468    }
3469
3470    #[test]
3471    fn test_from_env_errors_without_local_or_global_root_config() {
3472        let _guard = env_lock();
3473        let empty_global_dir = temp_dir();
3474
3475        unsafe {
3476            clear_from_env_overrides();
3477            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3478        }
3479
3480        let result = OpenMWConfiguration::from_env();
3481        unsafe { clear_from_env_overrides() };
3482
3483        assert!(
3484            matches!(result, Err(ConfigError::CannotFindRootConfig { .. })),
3485            "expected CannotFindRootConfig, got {result:?}"
3486        );
3487    }
3488
3489    #[test]
3490    #[cfg(not(windows))]
3491    fn test_from_env_or_user_config_falls_back_to_user_config() {
3492        let _guard = env_lock();
3493        let empty_global_dir = temp_dir();
3494        let test_home = temp_dir();
3495        let test_xdg_config_home = temp_dir();
3496        let old_home = std::env::var_os("HOME");
3497        let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
3498
3499        unsafe {
3500            clear_from_env_overrides();
3501            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3502            std::env::set_var("HOME", &test_home);
3503            std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
3504        }
3505
3506        let user_config_dir = crate::try_default_config_path().unwrap();
3507        std::fs::create_dir_all(&user_config_dir).unwrap();
3508        let user_cfg = write_cfg(&user_config_dir, "content=UserOnly.esm\n");
3509
3510        let result = OpenMWConfiguration::from_env_or_user_config();
3511        unsafe {
3512            clear_from_env_overrides();
3513        }
3514        restore_env_var("HOME", old_home);
3515        restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
3516
3517        let config = result.unwrap();
3518        assert_eq!(config.root_config_file(), user_cfg);
3519        assert!(config.has_content_file("UserOnly.esm"));
3520    }
3521
3522    #[test]
3523    #[cfg(not(windows))]
3524    fn test_from_env_or_user_config_errors_when_no_candidates_exist() {
3525        let _guard = env_lock();
3526        let empty_global_dir = temp_dir();
3527        let test_home = temp_dir();
3528        let test_xdg_config_home = temp_dir();
3529        let old_home = std::env::var_os("HOME");
3530        let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
3531
3532        unsafe {
3533            clear_from_env_overrides();
3534            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &empty_global_dir);
3535            std::env::set_var("HOME", &test_home);
3536            std::env::set_var("XDG_CONFIG_HOME", &test_xdg_config_home);
3537        }
3538
3539        let result = OpenMWConfiguration::from_env_or_user_config();
3540        unsafe {
3541            clear_from_env_overrides();
3542        }
3543        restore_env_var("HOME", old_home);
3544        restore_env_var("XDG_CONFIG_HOME", old_xdg_config_home);
3545
3546        assert!(
3547            matches!(result, Err(ConfigError::CannotFindAnyConfig { .. })),
3548            "expected CannotFindAnyConfig, got {result:?}"
3549        );
3550    }
3551
3552    #[test]
3553    fn test_global_root_config_loads_config_chain() {
3554        let _guard = env_lock();
3555        let global_dir = temp_dir();
3556        let user_dir = temp_dir();
3557        write_cfg(&user_dir, "content=UserConfig.esm\n");
3558        let root = write_cfg(
3559            &global_dir,
3560            &format!("content=GlobalRoot.esm\nconfig={}\n", user_dir.display()),
3561        );
3562
3563        unsafe {
3564            clear_from_env_overrides();
3565            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", &global_dir);
3566        }
3567
3568        let config = OpenMWConfiguration::from_env().unwrap();
3569        unsafe { clear_from_env_overrides() };
3570
3571        assert_eq!(config.root_config_file(), root);
3572        assert!(config.has_content_file("GlobalRoot.esm"));
3573        assert!(config.has_content_file("UserConfig.esm"));
3574    }
3575
3576    // -----------------------------------------------------------------------
3577    // ConfigError variants
3578    // -----------------------------------------------------------------------
3579
3580    #[test]
3581    fn test_error_duplicate_archive_file() {
3582        // The parser itself rejects duplicate fallback-archive= entries
3583        let dir = temp_dir();
3584        write_cfg(
3585            &dir,
3586            "fallback-archive=Morrowind.bsa\nfallback-archive=Morrowind.bsa\n",
3587        );
3588        let result = OpenMWConfiguration::new(Some(dir));
3589        assert!(matches!(
3590            result,
3591            Err(ConfigError::DuplicateArchiveFile { .. })
3592        ));
3593    }
3594
3595    #[test]
3596    fn test_error_cannot_add_groundcover_file() {
3597        let mut config = load("groundcover=GrassPlugin.esp\n");
3598        let result = config.add_groundcover_file("GrassPlugin.esp");
3599        assert!(matches!(
3600            result,
3601            Err(ConfigError::CannotAddGroundcoverFile { .. })
3602        ));
3603    }
3604
3605    #[test]
3606    fn test_error_cannot_find() {
3607        let result =
3608            OpenMWConfiguration::new(Some(PathBuf::from("/nonexistent/totally/fake/path")));
3609        assert!(matches!(
3610            result,
3611            Err(ConfigError::CannotFind(_) | ConfigError::NotFileOrDirectory(_))
3612        ));
3613    }
3614
3615    #[test]
3616    fn test_error_io_from_conversion() {
3617        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
3618        let config_err: ConfigError = io_err.into();
3619        assert!(matches!(config_err, ConfigError::Io(_)));
3620    }
3621
3622    #[test]
3623    fn test_error_invalid_line() {
3624        // A line with no `=` separator should produce InvalidLine
3625        let result = OpenMWConfiguration::new(Some({
3626            let dir = temp_dir();
3627            write_cfg(&dir, "this_has_no_equals_sign\n");
3628            dir
3629        }));
3630        assert!(matches!(
3631            result,
3632            Err(ConfigError::InvalidLine { line: Some(1), .. })
3633        ));
3634    }
3635
3636    #[test]
3637    fn test_error_max_depth_exceeded() {
3638        // Seventeen levels of distinct directories below the root.
3639        let dirs: Vec<PathBuf> = (0..18).map(|_| temp_dir()).collect();
3640        for pair in dirs.windows(2) {
3641            write_cfg(&pair[0], &format!("config={}\n", pair[1].display()));
3642        }
3643        write_cfg(&dirs[17], "");
3644        let result = OpenMWConfiguration::new(Some(dirs[0].clone()));
3645        assert!(matches!(result, Err(ConfigError::MaxDepthExceeded(_))));
3646    }
3647
3648    #[test]
3649    fn test_a_config_that_names_itself_loads_once() {
3650        // readConfiguration skips a config= directory it has already read ("Repeated config
3651        // dir"), the root's included.
3652        let dir = temp_dir();
3653        write_cfg(
3654            &dir,
3655            &format!("content=Self.esm\nconfig={}\n", dir.display()),
3656        );
3657        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3658
3659        assert_eq!(config.config_chain().count(), 1);
3660        assert_eq!(config.content_file_count(), 1);
3661        assert_eq!(config.user_config_path(), dir);
3662    }
3663
3664    #[test]
3665    fn test_a_circular_chain_loads_each_config_once() {
3666        let a = temp_dir();
3667        let b = temp_dir();
3668
3669        write_cfg(&a, &format!("content=A.esm\nconfig={}\n", b.display()));
3670        write_cfg(&b, &format!("content=B.esm\nconfig={}\n", a.display()));
3671
3672        let config = OpenMWConfiguration::new(Some(a.clone())).unwrap();
3673        let chain: Vec<PathBuf> = config
3674            .config_chain()
3675            .map(|entry| entry.path().to_path_buf())
3676            .collect();
3677        assert_eq!(chain, [a.join("openmw.cfg"), b.join("openmw.cfg")]);
3678        assert_eq!(config.content_file_count(), 2);
3679        assert_eq!(config.user_config_path(), b);
3680    }
3681
3682    #[cfg(unix)]
3683    #[test]
3684    fn test_symlinked_config_dir_loads_like_real_path() {
3685        let real_dir = temp_dir();
3686        write_cfg(&real_dir, "content=Morrowind.esm\n");
3687
3688        let link_parent = temp_dir();
3689        let link_path = link_parent.join("symlinked-config");
3690        if link_path.exists() {
3691            let _ = std::fs::remove_file(&link_path);
3692            let _ = std::fs::remove_dir_all(&link_path);
3693        }
3694        symlink_dir(&real_dir, &link_path);
3695
3696        let config = OpenMWConfiguration::new(Some(link_path.clone())).unwrap();
3697
3698        assert!(config.has_content_file("Morrowind.esm"));
3699        assert_eq!(config.root_config_file(), link_path.join("openmw.cfg"));
3700        assert_eq!(config.root_config_dir(), link_path);
3701    }
3702
3703    // -----------------------------------------------------------------------
3704    // settings_matching and clear_matching
3705    // -----------------------------------------------------------------------
3706
3707    #[test]
3708    fn test_settings_matching_filters_correctly() {
3709        let config = load("content=Morrowind.esm\nfallback-archive=Morrowind.bsa\n");
3710        let content_count = config
3711            .settings_matching(|s| matches!(s, SettingValue::ContentFile(_)))
3712            .count();
3713        assert_eq!(content_count, 1);
3714    }
3715
3716    #[test]
3717    fn test_clear_matching_removes_entries() {
3718        let mut config = load("content=Morrowind.esm\ncontent=Tribunal.esm\n");
3719        config.clear_matching(|s| matches!(s, SettingValue::ContentFile(_)));
3720        assert_eq!(config.content_files_iter().count(), 0);
3721    }
3722
3723    // -----------------------------------------------------------------------
3724    // sub_configs and config chaining
3725    // -----------------------------------------------------------------------
3726
3727    #[test]
3728    fn test_sub_configs_iteration() {
3729        let root_dir = temp_dir();
3730        let sub_dir = temp_dir();
3731        write_cfg(&sub_dir, "content=Plugin.esp\n");
3732        write_cfg(
3733            &root_dir,
3734            &format!("content=Morrowind.esm\nconfig={}\n", sub_dir.display()),
3735        );
3736
3737        let config = OpenMWConfiguration::new(Some(root_dir)).unwrap();
3738        assert_eq!(config.sub_configs().count(), 1);
3739        assert!(
3740            config.has_content_file("Plugin.esp"),
3741            "sub-config content visible in root"
3742        );
3743    }
3744
3745    // OpenMW's documentation (docs/source/reference/modding/paths.rst) says dir1 naming dir2
3746    // then dir3, and dir2 naming dir4, loads dir1, dir2, dir3, dir4. Its code does not:
3747    // ConfigurationManager::readConfiguration walks config= with a stack, so dir2 and all it
3748    // names load before dir3: dir1, dir2, dir4, dir3. The code wins.
3749    #[test]
3750    fn test_config_chain_priority_order_for_data_lists_is_depth_first() {
3751        let dir1 = temp_dir();
3752        let dir2 = temp_dir();
3753        let dir3 = temp_dir();
3754        let dir4 = temp_dir();
3755
3756        write_cfg(
3757            &dir1,
3758            &format!(
3759                "data=root-a\nconfig={}\nconfig={}\n",
3760                dir2.display(),
3761                dir3.display()
3762            ),
3763        );
3764        write_cfg(
3765            &dir2,
3766            &format!("data=branch-a\nconfig={}\n", dir4.display()),
3767        );
3768        write_cfg(&dir3, "data=sibling-a\n");
3769        write_cfg(&dir4, "data=leaf-a\n");
3770
3771        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3772        let actual: Vec<String> = config
3773            .data_directories_iter()
3774            .map(|setting| setting.original().clone())
3775            .collect();
3776
3777        assert_eq!(actual, vec!["root-a", "branch-a", "leaf-a", "sibling-a"]);
3778    }
3779
3780    #[test]
3781    fn test_replace_data_keeps_the_depth_first_order_in_a_branching_chain() {
3782        let dir1 = temp_dir();
3783        let dir2 = temp_dir();
3784        let dir3 = temp_dir();
3785        let dir4 = temp_dir();
3786
3787        write_cfg(
3788            &dir1,
3789            &format!(
3790                "data=root-a\nconfig={}\nconfig={}\n",
3791                dir2.display(),
3792                dir3.display()
3793            ),
3794        );
3795        write_cfg(
3796            &dir2,
3797            &format!("replace=data\ndata=branch-a\nconfig={}\n", dir4.display()),
3798        );
3799        write_cfg(&dir3, "data=sibling-a\n");
3800        write_cfg(&dir4, "data=leaf-a\n");
3801
3802        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3803        let actual: Vec<String> = config
3804            .data_directories_iter()
3805            .map(|setting| setting.original().clone())
3806            .collect();
3807
3808        assert_eq!(actual, vec!["branch-a", "leaf-a", "sibling-a"]);
3809    }
3810
3811    #[test]
3812    fn test_config_chain_priority_order_for_content_lists_is_depth_first() {
3813        let dir1 = temp_dir();
3814        let dir2 = temp_dir();
3815        let dir3 = temp_dir();
3816        let dir4 = temp_dir();
3817
3818        write_cfg(
3819            &dir1,
3820            &format!(
3821                "content=Root.esm\nconfig={}\nconfig={}\n",
3822                dir2.display(),
3823                dir3.display()
3824            ),
3825        );
3826        write_cfg(
3827            &dir2,
3828            &format!("content=Branch.esm\nconfig={}\n", dir4.display()),
3829        );
3830        write_cfg(&dir3, "content=Sibling.esm\n");
3831        write_cfg(&dir4, "content=Leaf.esm\n");
3832
3833        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3834        let actual: Vec<String> = config
3835            .content_files_iter()
3836            .map(|setting| setting.value().clone())
3837            .collect();
3838
3839        assert_eq!(
3840            actual,
3841            vec!["Root.esm", "Branch.esm", "Leaf.esm", "Sibling.esm"],
3842            "content= follows the order the chain loads in"
3843        );
3844    }
3845
3846    #[test]
3847    fn test_config_chain_priority_order_for_groundcover_lists_is_depth_first() {
3848        let dir1 = temp_dir();
3849        let dir2 = temp_dir();
3850        let dir3 = temp_dir();
3851        let dir4 = temp_dir();
3852
3853        write_cfg(
3854            &dir1,
3855            &format!(
3856                "groundcover=Root.esp\nconfig={}\nconfig={}\n",
3857                dir2.display(),
3858                dir3.display()
3859            ),
3860        );
3861        write_cfg(
3862            &dir2,
3863            &format!("groundcover=Branch.esp\nconfig={}\n", dir4.display()),
3864        );
3865        write_cfg(&dir3, "groundcover=Sibling.esp\n");
3866        write_cfg(&dir4, "groundcover=Leaf.esp\n");
3867
3868        let config = OpenMWConfiguration::new(Some(dir1)).unwrap();
3869        let actual: Vec<String> = config
3870            .groundcover_iter()
3871            .map(|setting| setting.value().clone())
3872            .collect();
3873
3874        assert_eq!(
3875            actual,
3876            vec!["Root.esp", "Branch.esp", "Leaf.esp", "Sibling.esp"],
3877            "groundcover= follows the order the chain loads in"
3878        );
3879    }
3880
3881    #[test]
3882    fn test_config_chain_priority_order_is_depth_first() {
3883        let dir1 = temp_dir();
3884        let dir2 = temp_dir();
3885        let dir3 = temp_dir();
3886        let dir4 = temp_dir();
3887
3888        write_cfg(
3889            &dir1,
3890            &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3891        );
3892        write_cfg(
3893            &dir2,
3894            &format!("encoding=win1250\nconfig={}\n", dir4.display()),
3895        );
3896        write_cfg(&dir3, "encoding=win1251\n");
3897        write_cfg(&dir4, "encoding=win1252\n");
3898
3899        let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3900
3901        assert_eq!(
3902            config.encoding().unwrap().to_string().trim(),
3903            "encoding=win1251"
3904        );
3905        assert_eq!(config.user_config_path(), dir3);
3906
3907        let chain: Vec<(PathBuf, usize)> = config
3908            .config_chain()
3909            .map(|entry| (entry.path().to_path_buf(), entry.depth()))
3910            .collect();
3911        assert_eq!(
3912            chain,
3913            [
3914                (dir1.join("openmw.cfg"), 0),
3915                (dir2.join("openmw.cfg"), 1),
3916                (dir4.join("openmw.cfg"), 2),
3917                (dir3.join("openmw.cfg"), 1),
3918            ]
3919        );
3920    }
3921
3922    #[test]
3923    fn test_config_chain_priority_order_with_user_data_crosscheck() {
3924        let dir1 = temp_dir();
3925        let dir2 = temp_dir();
3926        let dir3 = temp_dir();
3927        let dir4 = temp_dir();
3928
3929        write_cfg(
3930            &dir1,
3931            &format!("config={}\nconfig={}\n", dir2.display(), dir3.display()),
3932        );
3933        write_cfg(
3934            &dir2,
3935            &format!("user-data={}\nconfig={}\n", dir2.display(), dir4.display()),
3936        );
3937        write_cfg(&dir3, &format!("user-data={}\n", dir3.display()));
3938        write_cfg(&dir4, &format!("user-data={}\n", dir4.display()));
3939
3940        let config = OpenMWConfiguration::new(Some(dir1.clone())).unwrap();
3941
3942        assert_eq!(config.user_config_path(), dir3);
3943        assert_eq!(config.userdata().unwrap().parsed(), dir3.as_path());
3944    }
3945
3946    // -----------------------------------------------------------------------
3947    // root_config_file / root_config_dir
3948    // -----------------------------------------------------------------------
3949
3950    #[test]
3951    fn test_root_config_file_points_to_cfg() {
3952        let dir = temp_dir();
3953        write_cfg(&dir, "");
3954        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3955        assert_eq!(config.root_config_file(), dir.join("openmw.cfg"));
3956    }
3957
3958    #[test]
3959    fn test_root_config_dir_is_parent() {
3960        let dir = temp_dir();
3961        write_cfg(&dir, "");
3962        let config = OpenMWConfiguration::new(Some(dir.clone())).unwrap();
3963        assert_eq!(config.root_config_dir(), dir);
3964    }
3965
3966    // -----------------------------------------------------------------------
3967    // Clone
3968    // -----------------------------------------------------------------------
3969
3970    #[test]
3971    fn test_clone_is_independent() {
3972        let mut original = load("content=Morrowind.esm\n");
3973        let mut cloned = original.clone();
3974        cloned.add_content_file("Tribunal.esm").unwrap();
3975        original.add_content_file("Bloodmoon.esm").unwrap();
3976        assert!(cloned.has_content_file("Tribunal.esm"));
3977        assert!(!cloned.has_content_file("Bloodmoon.esm"));
3978        assert!(original.has_content_file("Bloodmoon.esm"));
3979        assert!(!original.has_content_file("Tribunal.esm"));
3980    }
3981
3982    fn assert_indexes_consistent(config: &OpenMWConfiguration) {
3983        use std::collections::HashSet;
3984
3985        config.ensure_game_setting_indexes();
3986
3987        let scanned_content: FxHashSet<String> = config
3988            .settings
3989            .iter()
3990            .filter_map(|setting| match setting {
3991                SettingValue::ContentFile(file) => Some(file.value().clone()),
3992                _ => None,
3993            })
3994            .collect();
3995        let scanned_groundcover: FxHashSet<String> = config
3996            .settings
3997            .iter()
3998            .filter_map(|setting| match setting {
3999                SettingValue::Groundcover(file) => Some(file.value().clone()),
4000                _ => None,
4001            })
4002            .collect();
4003        let scanned_archives: FxHashSet<String> = config
4004            .settings
4005            .iter()
4006            .filter_map(|setting| match setting {
4007                SettingValue::BethArchive(file) => Some(file.value().clone()),
4008                _ => None,
4009            })
4010            .collect();
4011        let scanned_data_dirs: FxHashSet<PathBuf> = config
4012            .settings
4013            .iter()
4014            .filter_map(|setting| match setting {
4015                SettingValue::DataDirectory(dir) => Some(dir.parsed().to_path_buf()),
4016                _ => None,
4017            })
4018            .collect();
4019
4020        let mut scanned_game_setting_last = FxHashMap::default();
4021        for (index, setting) in config.settings.iter().enumerate() {
4022            if let SettingValue::GameSetting(game_setting) = setting {
4023                scanned_game_setting_last.insert(game_setting.key().clone(), index);
4024            }
4025        }
4026
4027        let mut scanned_game_setting_order = Vec::new();
4028        let mut seen = HashSet::new();
4029        for (index, setting) in config.settings.iter().enumerate().rev() {
4030            if let SettingValue::GameSetting(game_setting) = setting
4031                && seen.insert(game_setting.key())
4032            {
4033                scanned_game_setting_order.push(index);
4034            }
4035        }
4036
4037        assert_eq!(config.indexed_content, scanned_content);
4038        assert_eq!(config.indexed_groundcover, scanned_groundcover);
4039        assert_eq!(config.indexed_archives, scanned_archives);
4040        assert_eq!(config.indexed_data_dirs, scanned_data_dirs);
4041        assert_eq!(
4042            *config.indexed_game_setting_last.borrow(),
4043            scanned_game_setting_last
4044        );
4045        assert_eq!(
4046            *config.indexed_game_setting_order.borrow(),
4047            scanned_game_setting_order
4048        );
4049
4050        for file in &config.indexed_content {
4051            assert!(config.has_content_file(file));
4052        }
4053        for file in &config.indexed_groundcover {
4054            assert!(config.has_groundcover_file(file));
4055        }
4056        for file in &config.indexed_archives {
4057            assert!(config.has_archive_file(file));
4058        }
4059        for dir in &config.indexed_data_dirs {
4060            assert!(config.has_data_dir(dir.to_string_lossy().as_ref()));
4061        }
4062
4063        let iter_keys: Vec<String> = config
4064            .game_settings()
4065            .map(|setting| setting.key().clone())
4066            .collect();
4067        let expected_keys: Vec<String> = config
4068            .indexed_game_setting_order
4069            .borrow()
4070            .iter()
4071            .filter_map(|index| match &config.settings[*index] {
4072                SettingValue::GameSetting(game_setting) => Some(game_setting.key().clone()),
4073                _ => None,
4074            })
4075            .collect();
4076        assert_eq!(iter_keys, expected_keys);
4077
4078        for (key, index) in config.indexed_game_setting_last.borrow().iter() {
4079            let expected_value = match &config.settings[*index] {
4080                SettingValue::GameSetting(game_setting) => game_setting.value(),
4081                _ => unreachable!("game setting index points to non-game setting"),
4082            };
4083            assert_eq!(
4084                config.get_game_setting(key).map(GameSettingType::value),
4085                Some(expected_value)
4086            );
4087        }
4088    }
4089
4090    #[test]
4091    fn test_indexes_remain_coherent_through_mutations() {
4092        let mut config = load(
4093            "content=Morrowind.esm\n\
4094content=Tribunal.esm\n\
4095groundcover=Grass.esp\n\
4096data=/tmp/data\n\
4097fallback-archive=Morrowind.bsa\n\
4098fallback=iGamma,1.00\n",
4099        );
4100        assert_indexes_consistent(&config);
4101
4102        config.add_content_file("Bloodmoon.esm").unwrap();
4103        assert_indexes_consistent(&config);
4104
4105        config.remove_content_file("Tribunal.esm");
4106        assert_indexes_consistent(&config);
4107
4108        config.add_groundcover_file("Flora.esp").unwrap();
4109        assert_indexes_consistent(&config);
4110
4111        config.remove_groundcover_file("Grass.esp");
4112        assert_indexes_consistent(&config);
4113
4114        config.add_archive_file("Tribunal.bsa").unwrap();
4115        assert_indexes_consistent(&config);
4116
4117        config.remove_archive_file("Morrowind.bsa");
4118        assert_indexes_consistent(&config);
4119
4120        config.add_data_directory(Path::new("/tmp/extra-data"));
4121        assert_indexes_consistent(&config);
4122
4123        config.remove_data_directory(&PathBuf::from("/tmp/data"));
4124        assert_indexes_consistent(&config);
4125
4126        config.set_content_files(Some(vec!["One.esp".to_string(), "Two.esp".to_string()]));
4127        assert_indexes_consistent(&config);
4128
4129        config.set_fallback_archives(Some(vec!["Only.bsa".to_string()]));
4130        assert_indexes_consistent(&config);
4131
4132        config
4133            .set_game_settings(Some(vec![
4134                "iFoo,10".to_string(),
4135                "iFoo,11".to_string(),
4136                "fBar,1.5".to_string(),
4137            ]))
4138            .unwrap();
4139        assert_indexes_consistent(&config);
4140
4141        let err = config.set_game_settings(Some(vec!["invalid-no-comma".to_string()]));
4142        assert!(err.is_err());
4143        assert_indexes_consistent(&config);
4144
4145        config.clear_matching(|setting| matches!(setting, SettingValue::ContentFile(_)));
4146        assert_indexes_consistent(&config);
4147    }
4148
4149    #[test]
4150    fn test_indexes_coherent_after_replace_during_load() {
4151        let config = load_chain(
4152            "content=Root.esm\n\
4153groundcover=GrassRoot.esp\n\
4154fallback-archive=Root.bsa\n\
4155fallback=iFoo,1\n",
4156            "replace=content\n\
4157content=AfterReplace.esm\n\
4158replace=groundcover\n\
4159groundcover=GrassAfter.esp\n\
4160replace=fallback-archive\n\
4161fallback-archive=After.bsa\n\
4162replace=fallback\n\
4163fallback=iFoo,2\n",
4164        );
4165
4166        assert_indexes_consistent(&config);
4167        assert!(config.has_content_file("AfterReplace.esm"));
4168        assert!(!config.has_content_file("Root.esm"));
4169        assert!(config.has_groundcover_file("GrassAfter.esp"));
4170        assert!(!config.has_groundcover_file("GrassRoot.esp"));
4171        assert!(config.has_archive_file("After.bsa"));
4172        assert!(!config.has_archive_file("Root.bsa"));
4173        assert_eq!(
4174            config.get_game_setting("iFoo").map(GameSettingType::value),
4175            Some("2".into())
4176        );
4177    }
4178}