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