Skip to main content

openmw_config/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4//! Parser, resolver, and serializer for `OpenMW` configuration chains.
5//!
6//! `OpenMW` loads one or more `openmw.cfg` files in a chain: the root config can reference
7//! additional configs via `config=` entries, and each file can accumulate or override settings
8//! from its parent.  This crate walks that chain, resolves token substitutions
9//! (`?local?`, `?global?`, `?userdata?`, `?userconfig?`), normalises paths, and exposes the composed result as
10//! [`OpenMWConfiguration`].
11//!
12//! # Quick start
13//!
14//! ```no_run
15//! use openmw_config::OpenMWConfiguration;
16//!
17//! // Load using OpenMW-style root config discovery.
18//! // OPENMW_CONFIG and OPENMW_CONFIG_DIR override discovery.
19//! let config = OpenMWConfiguration::from_env()?;
20//!
21//! // Iterate content files in load order
22//! for plugin in config.content_files_iter() {
23//!     println!("{}", plugin.value());
24//! }
25//! # Ok::<(), openmw_config::ConfigError>(())
26//! ```
27//!
28//! # Configuration sources
29//!
30//! See the [OpenMW path documentation](https://openmw.readthedocs.io/en/latest/reference/modding/paths.html)
31//! for platform-specific default locations.  The environment variables `OPENMW_CONFIG` (path to
32//! an `openmw.cfg` file) and `OPENMW_CONFIG_DIR` (directory containing `openmw.cfg`) override root
33//! config discovery. Without those, [`OpenMWConfiguration::from_env`] tries an `openmw.cfg` adjacent
34//! to the running executable, then the platform global `OpenMW` config. User config is loaded only
35//! when referenced by the root config, usually through `config="?userconfig?"`. External tools that
36//! want this order plus a user-config fallback should use
37//! [`OpenMWConfiguration::from_env_or_user_config`].
38//!
39//! Path helpers intentionally distinguish the user config path (`?userconfig?`,
40//! [`try_default_config_path`]), the user config file ([`try_default_user_config_file`]), the global
41//! config path ([`try_default_global_config_path`]), and the global data-token path (`?global?`,
42//! [`try_default_global_path`]). Those are not synonyms.
43//!
44//! Serialization has two contracts. [`OpenMWConfiguration`]'s [`std::fmt::Display`] implementation
45//! and preservation save APIs keep directory settings in their original spelling for round-trips.
46//! [`OpenMWConfiguration::to_resolved_string`] and
47//! [`OpenMWConfiguration::save_resolved_to_path`] emit flattened relocation-safe output, resolving
48//! directory values and omitting chain-control entries such as `config=` and `replace=`.
49
50mod config;
51#[cfg(feature = "luau")]
52pub mod luau;
53mod platform_paths;
54
55pub use config::{
56    ConfigChainEntry, ConfigChainStatus, OpenMWConfiguration, SettingValue, TrailingComment,
57    directorysetting::DirectorySetting,
58    encodingsetting::{EncodingSetting, EncodingType},
59    error::ConfigError,
60    filesetting::FileSetting,
61    gamesetting::GameSettingType,
62    genericsetting::GenericSetting,
63};
64
65pub(crate) trait GameSetting: std::fmt::Display {
66    fn meta(&self) -> &GameSettingMeta;
67    fn meta_mut(&mut self) -> &mut GameSettingMeta;
68}
69
70/// Source-tracking metadata attached to every setting value.
71///
72/// Records which config file defined the setting and any comment lines that
73/// immediately preceded it in the file, so that [`OpenMWConfiguration`]'s
74/// `Display` implementation can round-trip comments faithfully.
75#[derive(Debug, Clone, Eq, PartialEq)]
76pub struct GameSettingMeta {
77    source_config: std::path::PathBuf,
78    comment: String,
79}
80
81impl GameSettingMeta {
82    #[must_use]
83    pub fn source_config(&self) -> &std::path::Path {
84        &self.source_config
85    }
86
87    #[must_use]
88    pub fn comment(&self) -> &str {
89        &self.comment
90    }
91}
92
93const NO_CONFIG_DIR: &str = "FAILURE: COULD NOT READ CONFIG DIRECTORY";
94const NO_LOCAL_DIR: &str = "FAILURE: COULD NOT READ LOCAL DIRECTORY";
95const NO_GLOBAL_DIR: &str = "FAILURE: COULD NOT READ GLOBAL DIRECTORY";
96const NO_GLOBAL_CONFIG_DIR: &str = "FAILURE: COULD NOT READ GLOBAL CONFIG DIRECTORY";
97const DEFAULT_FLATPAK_APP_ID: &str = "org.openmw.OpenMW";
98
99#[cfg(target_os = "linux")]
100#[cfg(target_os = "linux")]
101fn has_flatpak_info_file() -> bool {
102    use std::sync::OnceLock;
103
104    static HAS_FLATPAK_INFO: OnceLock<bool> = OnceLock::new();
105    *HAS_FLATPAK_INFO.get_or_init(|| std::path::Path::new("/.flatpak-info").exists())
106}
107
108#[cfg(target_os = "linux")]
109fn flatpak_mode_enabled() -> bool {
110    std::env::var_os("OPENMW_CONFIG_USING_FLATPAK").is_some()
111        || std::env::var_os("FLATPAK_ID").is_some()
112        || has_flatpak_info_file()
113}
114
115#[cfg(not(target_os = "linux"))]
116fn flatpak_mode_enabled() -> bool {
117    false
118}
119
120fn flatpak_app_id() -> String {
121    std::env::var("OPENMW_FLATPAK_ID")
122        .ok()
123        .filter(|value| !value.trim().is_empty())
124        .or_else(|| {
125            std::env::var("FLATPAK_ID")
126                .ok()
127                .filter(|value| !value.trim().is_empty())
128        })
129        .unwrap_or_else(|| DEFAULT_FLATPAK_APP_ID.to_string())
130}
131
132fn flatpak_userconfig_path() -> Result<std::path::PathBuf, ConfigError> {
133    platform_paths::home_dir().map(|home| {
134        home.join(".var")
135            .join("app")
136            .join(flatpak_app_id())
137            .join("config")
138            .join("openmw")
139    })
140}
141
142fn flatpak_userdata_path() -> Result<std::path::PathBuf, ConfigError> {
143    platform_paths::home_dir().map(|home| {
144        home.join(".var")
145            .join("app")
146            .join(flatpak_app_id())
147            .join("data")
148            .join("openmw")
149    })
150}
151
152/// Fallible variant of [`default_config_path`].
153///
154/// Resolution precedence:
155/// 1. Flatpak mode path (`$HOME/.var/app/<app-id>/config/openmw`) when Flatpak mode is enabled.
156/// 2. Platform default path from platform-specific resolvers.
157///
158/// On Linux, Flatpak mode is enabled when `OPENMW_CONFIG_USING_FLATPAK` is set to any value, or
159/// auto-detected via `FLATPAK_ID` / `/.flatpak-info`.
160///
161/// # Errors
162/// Returns [`ConfigError::PlatformPathUnavailable`] if no platform config directory can be discovered.
163pub fn try_default_config_path() -> Result<std::path::PathBuf, ConfigError> {
164    #[cfg(target_os = "android")]
165    return Ok(std::path::PathBuf::from(
166        "/storage/emulated/0/Alpha3/config",
167    ));
168
169    #[cfg(not(target_os = "android"))]
170    {
171        if flatpak_mode_enabled() {
172            return flatpak_userconfig_path();
173        }
174
175        platform_paths::config_dir().map_err(|_| ConfigError::PlatformPathUnavailable("config"))
176    }
177}
178
179/// Path to input bindings and core configuration
180/// These functions are not expected to fail and should they fail, indicate either:
181/// a severe issue with the system
182/// or that an unsupported system is being used.
183///
184/// # Panics
185/// Panics if the platform config directory cannot be determined (unsupported system).
186#[must_use]
187pub fn default_config_path() -> std::path::PathBuf {
188    try_default_config_path().expect(NO_CONFIG_DIR)
189}
190
191/// Fallible variant of [`default_user_config_file`].
192///
193/// Resolves the default user `openmw.cfg` file, i.e. [`try_default_config_path`] plus
194/// `openmw.cfg`. This is the file behind the `?userconfig?` token, not `OpenMW`'s root startup
195/// config.
196///
197/// # Errors
198/// Returns [`ConfigError::PlatformPathUnavailable`] if no platform config directory can be discovered.
199pub fn try_default_user_config_file() -> Result<std::path::PathBuf, ConfigError> {
200    try_default_config_path().map(|path| path.join("openmw.cfg"))
201}
202
203/// Path to the default user `openmw.cfg` file.
204///
205/// # Panics
206/// Panics if the platform config directory cannot be determined (unsupported system).
207#[must_use]
208pub fn default_user_config_file() -> std::path::PathBuf {
209    try_default_user_config_file().expect(NO_CONFIG_DIR)
210}
211
212/// Fallible variant of [`default_userdata_path`].
213///
214/// Resolution precedence:
215/// 1. Flatpak mode path (`$HOME/.var/app/<app-id>/data/openmw`) when Flatpak mode is enabled.
216/// 2. Platform default path from platform-specific resolvers.
217///
218/// On Linux, Flatpak mode is enabled when `OPENMW_CONFIG_USING_FLATPAK` is set to any value, or
219/// auto-detected via `FLATPAK_ID` / `/.flatpak-info`.
220///
221/// # Errors
222/// Returns [`ConfigError::PlatformPathUnavailable`] if no platform userdata directory can be discovered.
223pub fn try_default_userdata_path() -> Result<std::path::PathBuf, ConfigError> {
224    #[cfg(target_os = "android")]
225    return Ok(std::path::PathBuf::from("/storage/emulated/0/Alpha3"));
226
227    #[cfg(not(target_os = "android"))]
228    {
229        if flatpak_mode_enabled() {
230            return flatpak_userdata_path();
231        }
232
233        platform_paths::data_dir().map_err(|_| ConfigError::PlatformPathUnavailable("userdata"))
234    }
235}
236
237/// Path to save storage, screenshots, navmeshdb, and data-local
238/// These functions are not expected to fail and should they fail, indicate either:
239/// a severe issue with the system
240/// or that an unsupported system is being used.
241///
242/// # Panics
243/// Panics if the platform data directory cannot be determined (unsupported system).
244#[must_use]
245pub fn default_userdata_path() -> std::path::PathBuf {
246    try_default_userdata_path().expect("FAILURE: COULD NOT READ USERDATA DIRECTORY")
247}
248
249/// Path to the `data-local` directory as defined by the engine's defaults.
250///
251/// This directory is loaded last and therefore overrides all other data sources
252/// in the VFS load order.
253#[must_use]
254pub fn default_data_local_path() -> std::path::PathBuf {
255    default_userdata_path().join("data")
256}
257
258/// Fallible variant of [`default_local_path`].
259///
260/// Resolves the `?local?` token target.
261///
262/// - On macOS app bundles, this is the `Contents/Resources` directory.
263/// - On other platforms, this is the directory containing the running executable.
264///
265/// # Errors
266/// Returns [`ConfigError::PlatformPathUnavailable`] if the local path cannot be determined.
267pub fn try_default_local_path() -> Result<std::path::PathBuf, ConfigError> {
268    let exe = std::env::current_exe()?;
269
270    #[cfg(target_os = "macos")]
271    {
272        if let Some(macos_dir) = exe.parent()
273            && macos_dir.file_name() == Some(std::ffi::OsStr::new("MacOS"))
274            && let Some(contents_dir) = macos_dir.parent()
275            && contents_dir.file_name() == Some(std::ffi::OsStr::new("Contents"))
276        {
277            return Ok(contents_dir.join("Resources"));
278        }
279    }
280
281    exe.parent()
282        .map(std::path::Path::to_path_buf)
283        .ok_or(ConfigError::PlatformPathUnavailable("local"))
284}
285
286/// Path that backs the `?local?` token.
287///
288/// # Panics
289/// Panics if the local path cannot be determined.
290#[must_use]
291pub fn default_local_path() -> std::path::PathBuf {
292    try_default_local_path().expect(NO_LOCAL_DIR)
293}
294
295/// Find the default root `openmw.cfg` using `OpenMW`'s root config discovery order.
296///
297/// This is not the same as [`try_default_config_path`]. The latter resolves the `?userconfig?`
298/// token. Root discovery starts from `OpenMW`'s baseline config: first an executable-adjacent
299/// `openmw.cfg`, then the platform global config. The user config is loaded only if that root
300/// config references it, normally via `config="?userconfig?"`.
301///
302/// Windows and macOS have no global config directory, so there the executable-adjacent config is
303/// the only candidate.
304///
305/// # Errors
306/// Returns [`ConfigError::CannotFindRootConfig`] if no root `openmw.cfg` exists, or another
307/// [`ConfigError`] if the executable's directory cannot be resolved.
308pub fn try_default_root_config_path() -> Result<std::path::PathBuf, ConfigError> {
309    let local_dir = try_default_local_path()?;
310    let global_config_dir = match try_default_global_config_path() {
311        Ok(global_config_dir) => Some(global_config_dir),
312        Err(ConfigError::PlatformPathUnavailable(_)) => None,
313        Err(error) => return Err(error),
314    };
315
316    discover_root_config_path(&local_dir, global_config_dir.as_deref())
317}
318
319/// Find the default root `openmw.cfg`, falling back to the default user `openmw.cfg`.
320///
321/// This is intended for external tools that should honor package/global root configs when present,
322/// but should still work on installs that only have a user config. It does not change
323/// [`try_default_root_config_path`], which remains strict `OpenMW` startup/root discovery.
324///
325/// # Errors
326/// Returns [`ConfigError`] if platform paths cannot be resolved or no root/user `openmw.cfg` exists.
327pub fn try_default_root_or_user_config_path() -> Result<std::path::PathBuf, ConfigError> {
328    match try_default_root_config_path() {
329        Ok(path) => Ok(path),
330        Err(ConfigError::CannotFindRootConfig { local, global }) => {
331            let user = try_default_user_config_file()?;
332
333            if user.is_file() {
334                Ok(user)
335            } else {
336                Err(ConfigError::CannotFindAnyConfig {
337                    local,
338                    global,
339                    user,
340                })
341            }
342        }
343        Err(error) => Err(error),
344    }
345}
346
347/// Path to the default root-or-user `openmw.cfg` discovered for external tools.
348///
349/// # Panics
350/// Panics if no root or user config can be found.
351#[must_use]
352pub fn default_root_or_user_config_path() -> std::path::PathBuf {
353    try_default_root_or_user_config_path().expect("FAILURE: COULD NOT FIND ROOT OR USER CONFIG")
354}
355
356/// Path to the default root `openmw.cfg` discovered with `OpenMW` startup semantics.
357///
358/// # Panics
359/// Panics if no root config can be found.
360#[must_use]
361pub fn default_root_config_path() -> std::path::PathBuf {
362    try_default_root_config_path().expect("FAILURE: COULD NOT FIND ROOT CONFIG")
363}
364
365/// The root config among the candidates: executable-adjacent first, then global. Without a global
366/// config directory, [`ConfigError::CannotFindRootConfig`] carries an empty `global` path.
367pub(crate) fn discover_root_config_path(
368    local_dir: &std::path::Path,
369    global_config_dir: Option<&std::path::Path>,
370) -> Result<std::path::PathBuf, ConfigError> {
371    let local = local_dir.join("openmw.cfg");
372    if local.is_file() {
373        return Ok(local);
374    }
375
376    let global =
377        global_config_dir.map_or_else(std::path::PathBuf::new, |dir| dir.join("openmw.cfg"));
378    if global_config_dir.is_some() && global.is_file() {
379        return Ok(global);
380    }
381
382    Err(ConfigError::CannotFindRootConfig { local, global })
383}
384
385/// Fallible variant of [`default_global_config_path`].
386///
387/// Resolves `OpenMW`'s global **config** directory, not the `?global?` data-token target. On normal
388/// Linux package installs this is `/etc/openmw`; in Flatpak mode it is `/app/etc/openmw`.
389///
390/// `OPENMW_GLOBAL_CONFIG_PATH`, when set to a non-empty value, overrides the detected directory.
391/// This mirrors the explicit override style used by the other path helpers and keeps packager tests
392/// from needing to write to `/etc`, which is a generally poor hobby.
393///
394/// # Errors
395/// Returns [`ConfigError::PlatformPathUnavailable`] on platforms where `OpenMW` has no global config
396/// directory concept.
397pub fn try_default_global_config_path() -> Result<std::path::PathBuf, ConfigError> {
398    if let Ok(value) = std::env::var("OPENMW_GLOBAL_CONFIG_PATH")
399        && !value.trim().is_empty()
400    {
401        return Ok(std::path::PathBuf::from(value));
402    }
403
404    if cfg!(windows) || cfg!(target_os = "macos") {
405        return Err(ConfigError::PlatformPathUnavailable("global_config"));
406    }
407
408    if flatpak_mode_enabled() {
409        return Ok(std::path::PathBuf::from("/app/etc/openmw"));
410    }
411
412    Ok(std::path::PathBuf::from("/etc/openmw"))
413}
414
415/// Path to `OpenMW`'s global **config** directory.
416///
417/// This is distinct from [`default_global_path`], which backs the `?global?` data token.
418///
419/// # Panics
420/// Panics if the global config directory cannot be determined.
421#[must_use]
422pub fn default_global_config_path() -> std::path::PathBuf {
423    try_default_global_config_path().expect(NO_GLOBAL_CONFIG_DIR)
424}
425
426/// Fallible variant of [`default_global_path`].
427///
428/// Resolves the `?global?` token target.
429///
430/// Resolution order:
431/// 1. `OPENMW_GLOBAL_PATH` when set.
432/// 2. Flatpak default (`/app/share/games`) when Flatpak mode is active.
433/// 3. Platform default (`/usr/share/games` on Unix-like systems, `/Library/Application Support` on macOS).
434///
435/// Flatpak app id selection is: `OPENMW_FLATPAK_ID` > `FLATPAK_ID` > `org.openmw.OpenMW`.
436///
437/// # Errors
438/// Returns [`ConfigError::PlatformPathUnavailable`] on unsupported platforms.
439pub fn try_default_global_path() -> Result<std::path::PathBuf, ConfigError> {
440    if let Ok(value) = std::env::var("OPENMW_GLOBAL_PATH")
441        && !value.trim().is_empty()
442    {
443        return Ok(std::path::PathBuf::from(value));
444    }
445
446    if cfg!(windows) {
447        return Err(ConfigError::PlatformPathUnavailable("global"));
448    }
449
450    // NOTE: Flatpak path behavior is intentionally Linux-only.
451    // We are not fully certain whether OpenMW Flatpak builds should prefer a global
452    // or local config path in all packaging variants, so we keep this conservative:
453    // only Linux Flatpak mode maps ?global? to /app/share/games.
454    if flatpak_mode_enabled() {
455        return Ok(std::path::PathBuf::from("/app/share/games"));
456    }
457
458    if cfg!(target_os = "macos") {
459        return Ok(std::path::PathBuf::from("/Library/Application Support"));
460    }
461
462    Ok(std::path::PathBuf::from("/usr/share/games"))
463}
464
465/// Path that backs the `?global?` token.
466///
467/// # Panics
468/// Panics if the global path cannot be determined.
469#[must_use]
470pub fn default_global_path() -> std::path::PathBuf {
471    try_default_global_path().expect(NO_GLOBAL_DIR)
472}
473
474#[cfg(test)]
475pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
476    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
477    LOCK.get_or_init(|| std::sync::Mutex::new(()))
478        .lock()
479        .unwrap_or_else(std::sync::PoisonError::into_inner)
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use std::ffi::OsString;
486
487    fn snapshot_env(keys: &[&str]) -> Vec<(String, Option<OsString>)> {
488        keys.iter()
489            .map(|key| ((*key).to_string(), std::env::var_os(key)))
490            .collect()
491    }
492
493    fn restore_env(snapshot: Vec<(String, Option<OsString>)>) {
494        for (key, value) in snapshot {
495            // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
496            unsafe {
497                if let Some(value) = value {
498                    std::env::set_var(&key, value);
499                } else {
500                    std::env::remove_var(&key);
501                }
502            }
503        }
504    }
505
506    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
507        std::env::temp_dir().join(format!(
508            "openmw_config_{name}_{}_{}",
509            std::process::id(),
510            std::time::SystemTime::now()
511                .duration_since(std::time::UNIX_EPOCH)
512                .unwrap()
513                .as_nanos()
514        ))
515    }
516
517    #[test]
518    fn test_default_data_local_path_is_userdata_data_child() {
519        let _guard = crate::test_env_lock();
520        let snapshot = snapshot_env(&[
521            "OPENMW_CONFIG_USING_FLATPAK",
522            "OPENMW_FLATPAK_ID",
523            "FLATPAK_ID",
524            "OPENMW_GLOBAL_PATH",
525        ]);
526
527        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
528        unsafe {
529            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
530            std::env::remove_var("OPENMW_FLATPAK_ID");
531            std::env::remove_var("FLATPAK_ID");
532            std::env::remove_var("OPENMW_GLOBAL_PATH");
533        }
534
535        assert_eq!(
536            default_data_local_path(),
537            default_userdata_path().join("data")
538        );
539
540        restore_env(snapshot);
541    }
542
543    #[test]
544    #[cfg(windows)]
545    fn test_windows_default_paths_contract() {
546        let cfg = default_config_path();
547        let cfg_str = cfg.to_string_lossy().to_lowercase();
548        assert!(cfg_str.contains("my games"));
549        assert!(cfg_str.contains("openmw"));
550        assert_eq!(default_userdata_path(), cfg);
551    }
552
553    #[test]
554    fn test_try_default_config_path_returns_path_or_error() {
555        let _guard = crate::test_env_lock();
556        let snapshot = snapshot_env(&[
557            "OPENMW_CONFIG_USING_FLATPAK",
558            "OPENMW_FLATPAK_ID",
559            "FLATPAK_ID",
560        ]);
561        let _ = try_default_config_path();
562        restore_env(snapshot);
563    }
564
565    #[test]
566    fn test_try_default_local_path_returns_path_or_error() {
567        let _guard = crate::test_env_lock();
568        let snapshot = snapshot_env(&[
569            "OPENMW_CONFIG_USING_FLATPAK",
570            "OPENMW_FLATPAK_ID",
571            "FLATPAK_ID",
572        ]);
573        let _ = try_default_local_path();
574        restore_env(snapshot);
575    }
576
577    #[test]
578    #[cfg(target_os = "linux")]
579    fn test_flatpak_env_flag_forces_flatpak_paths() {
580        let _guard = crate::test_env_lock();
581        let Ok(home) = platform_paths::home_dir() else {
582            return;
583        };
584
585        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
586        unsafe {
587            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "bananas");
588            std::env::remove_var("OPENMW_FLATPAK_ID");
589            std::env::remove_var("FLATPAK_ID");
590        }
591
592        let cfg = try_default_config_path().expect("flatpak config path should resolve");
593        let data = try_default_userdata_path().expect("flatpak userdata path should resolve");
594
595        assert_eq!(
596            cfg,
597            home.join(".var")
598                .join("app")
599                .join(DEFAULT_FLATPAK_APP_ID)
600                .join("config")
601                .join("openmw")
602        );
603        assert_eq!(
604            data,
605            home.join(".var")
606                .join("app")
607                .join(DEFAULT_FLATPAK_APP_ID)
608                .join("data")
609                .join("openmw")
610        );
611
612        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
613        unsafe {
614            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
615        }
616    }
617
618    #[test]
619    #[cfg(target_os = "linux")]
620    fn test_flatpak_app_id_override_precedence() {
621        let _guard = crate::test_env_lock();
622        let Ok(home) = platform_paths::home_dir() else {
623            return;
624        };
625
626        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
627        unsafe {
628            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "enabled");
629            std::env::set_var("OPENMW_FLATPAK_ID", "org.example.Override");
630            std::env::set_var("FLATPAK_ID", "org.example.ShouldNotWin");
631        }
632
633        let cfg = try_default_config_path().expect("flatpak config path should resolve");
634        assert_eq!(
635            cfg,
636            home.join(".var")
637                .join("app")
638                .join("org.example.Override")
639                .join("config")
640                .join("openmw")
641        );
642
643        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
644        unsafe {
645            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
646            std::env::remove_var("OPENMW_FLATPAK_ID");
647            std::env::remove_var("FLATPAK_ID");
648        }
649    }
650
651    #[test]
652    #[cfg(target_os = "linux")]
653    fn test_flatpak_auto_detect_via_flatpak_id() {
654        let _guard = crate::test_env_lock();
655        let Ok(home) = platform_paths::home_dir() else {
656            return;
657        };
658
659        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
660        unsafe {
661            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
662            std::env::remove_var("OPENMW_FLATPAK_ID");
663            std::env::set_var("FLATPAK_ID", "org.example.AutoDetect");
664        }
665
666        let data = try_default_userdata_path().expect("flatpak userdata path should resolve");
667        assert_eq!(
668            data,
669            home.join(".var")
670                .join("app")
671                .join("org.example.AutoDetect")
672                .join("data")
673                .join("openmw")
674        );
675
676        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
677        unsafe {
678            std::env::remove_var("FLATPAK_ID");
679        }
680    }
681
682    #[test]
683    fn test_global_path_env_override_has_precedence() {
684        let _guard = crate::test_env_lock();
685        let expected = std::path::PathBuf::from("/opt/openmw/global");
686
687        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
688        unsafe {
689            std::env::set_var("OPENMW_GLOBAL_PATH", expected.as_os_str());
690        }
691
692        assert_eq!(
693            try_default_global_path().expect("global override should be used"),
694            expected
695        );
696
697        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
698        unsafe {
699            std::env::remove_var("OPENMW_GLOBAL_PATH");
700        }
701    }
702
703    #[test]
704    fn test_root_config_discovery_uses_local_before_global_config() {
705        let local_dir = unique_temp_dir("root_local");
706        let global_dir = unique_temp_dir("root_global");
707        std::fs::create_dir_all(&local_dir).unwrap();
708        std::fs::create_dir_all(&global_dir).unwrap();
709        let local_cfg = local_dir.join("openmw.cfg");
710        let global_cfg = global_dir.join("openmw.cfg");
711        std::fs::write(&local_cfg, "content=Local.esm\n").unwrap();
712        std::fs::write(&global_cfg, "content=Global.esm\n").unwrap();
713
714        assert_eq!(
715            discover_root_config_path(&local_dir, Some(&global_dir)).unwrap(),
716            local_cfg
717        );
718
719        let _ = std::fs::remove_file(global_cfg);
720        let _ = std::fs::remove_file(local_cfg);
721        let _ = std::fs::remove_dir_all(global_dir);
722        let _ = std::fs::remove_dir_all(local_dir);
723    }
724
725    #[test]
726    fn test_root_config_discovery_uses_global_config_when_local_missing() {
727        let local_dir = unique_temp_dir("root_local_missing");
728        let global_dir = unique_temp_dir("root_global_present");
729        std::fs::create_dir_all(&local_dir).unwrap();
730        std::fs::create_dir_all(&global_dir).unwrap();
731        let global_cfg = global_dir.join("openmw.cfg");
732        std::fs::write(&global_cfg, "content=Global.esm\n").unwrap();
733
734        assert_eq!(
735            discover_root_config_path(&local_dir, Some(&global_dir)).unwrap(),
736            global_cfg
737        );
738
739        let _ = std::fs::remove_file(global_dir.join("openmw.cfg"));
740        let _ = std::fs::remove_dir_all(global_dir);
741        let _ = std::fs::remove_dir_all(local_dir);
742    }
743
744    #[test]
745    fn test_root_config_discovery_errors_without_user_fallback() {
746        let local_dir = unique_temp_dir("root_none_local");
747        let global_dir = unique_temp_dir("root_none_global");
748        std::fs::create_dir_all(&local_dir).unwrap();
749        std::fs::create_dir_all(&global_dir).unwrap();
750
751        assert!(matches!(
752            discover_root_config_path(&local_dir, Some(&global_dir)),
753            Err(ConfigError::CannotFindRootConfig { .. })
754        ));
755
756        let _ = std::fs::remove_dir_all(global_dir);
757        let _ = std::fs::remove_dir_all(local_dir);
758    }
759
760    #[test]
761    fn test_root_config_discovery_without_a_global_config_directory() {
762        let local_dir = unique_temp_dir("root_no_global_local");
763        std::fs::create_dir_all(&local_dir).unwrap();
764
765        match discover_root_config_path(&local_dir, None) {
766            Err(ConfigError::CannotFindRootConfig { local, global }) => {
767                assert_eq!(local, local_dir.join("openmw.cfg"));
768                assert!(global.as_os_str().is_empty());
769            }
770            other => panic!("expected CannotFindRootConfig, got {other:?}"),
771        }
772
773        let local_cfg = local_dir.join("openmw.cfg");
774        std::fs::write(&local_cfg, "content=Local.esm\n").unwrap();
775        assert_eq!(
776            discover_root_config_path(&local_dir, None).unwrap(),
777            local_cfg
778        );
779
780        let _ = std::fs::remove_file(local_cfg);
781        let _ = std::fs::remove_dir_all(local_dir);
782    }
783
784    #[test]
785    #[cfg(target_os = "linux")]
786    fn test_global_config_path_is_not_global_data_token_path() {
787        let _guard = crate::test_env_lock();
788
789        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
790        unsafe {
791            std::env::remove_var("OPENMW_GLOBAL_PATH");
792            std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
793            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
794            std::env::remove_var("FLATPAK_ID");
795        }
796
797        assert_eq!(
798            try_default_global_path().unwrap(),
799            std::path::PathBuf::from("/usr/share/games")
800        );
801        assert_eq!(
802            try_default_global_config_path().unwrap(),
803            std::path::PathBuf::from("/etc/openmw")
804        );
805    }
806
807    #[test]
808    fn test_global_config_path_env_override_has_precedence() {
809        let _guard = crate::test_env_lock();
810        let expected = std::path::PathBuf::from("/opt/openmw/etc/openmw");
811
812        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
813        unsafe {
814            std::env::set_var("OPENMW_GLOBAL_CONFIG_PATH", expected.as_os_str());
815        }
816
817        assert_eq!(
818            try_default_global_config_path().expect("global config override should be used"),
819            expected
820        );
821
822        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
823        unsafe {
824            std::env::remove_var("OPENMW_GLOBAL_CONFIG_PATH");
825        }
826    }
827
828    #[test]
829    #[cfg(not(windows))]
830    fn test_global_path_default_is_platform_or_flatpak_value() {
831        let _guard = crate::test_env_lock();
832
833        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
834        unsafe {
835            std::env::remove_var("OPENMW_GLOBAL_PATH");
836            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
837            std::env::remove_var("FLATPAK_ID");
838        }
839
840        if cfg!(target_os = "macos") {
841            assert_eq!(
842                try_default_global_path().expect("macOS global path should resolve"),
843                std::path::PathBuf::from("/Library/Application Support")
844            );
845        } else if flatpak_mode_enabled() {
846            assert_eq!(
847                try_default_global_path().expect("flatpak global path should resolve"),
848                std::path::PathBuf::from("/app/share/games")
849            );
850        } else {
851            assert_eq!(
852                try_default_global_path().expect("unix global path should resolve"),
853                std::path::PathBuf::from("/usr/share/games")
854            );
855        }
856    }
857
858    #[test]
859    #[cfg(windows)]
860    fn test_global_path_is_unavailable_on_windows_without_override() {
861        let _guard = crate::test_env_lock();
862
863        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
864        unsafe {
865            std::env::remove_var("OPENMW_GLOBAL_PATH");
866            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
867            std::env::remove_var("FLATPAK_ID");
868        }
869
870        assert!(matches!(
871            try_default_global_path(),
872            Err(ConfigError::PlatformPathUnavailable("global"))
873        ));
874    }
875
876    #[test]
877    #[cfg(not(target_os = "linux"))]
878    fn test_flatpak_mode_is_ignored_off_linux() {
879        let _guard = crate::test_env_lock();
880
881        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
882        unsafe {
883            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "1");
884            std::env::set_var("FLATPAK_ID", "org.example.Flatpak");
885            std::env::remove_var("OPENMW_GLOBAL_PATH");
886        }
887
888        assert!(!flatpak_mode_enabled());
889
890        assert_eq!(
891            try_default_config_path().ok(),
892            platform_paths::config_dir().ok()
893        );
894        assert_eq!(
895            try_default_userdata_path().ok(),
896            platform_paths::data_dir().ok()
897        );
898
899        if cfg!(windows) {
900            assert!(matches!(
901                try_default_global_path(),
902                Err(ConfigError::PlatformPathUnavailable("global"))
903            ));
904        } else if cfg!(target_os = "macos") {
905            assert_eq!(
906                try_default_global_path().expect("macOS global path should resolve"),
907                std::path::PathBuf::from("/Library/Application Support")
908            );
909        }
910
911        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
912        unsafe {
913            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
914            std::env::remove_var("FLATPAK_ID");
915            std::env::remove_var("OPENMW_GLOBAL_PATH");
916        }
917    }
918}