Skip to main content

openmw_config/
lib.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
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 from the platform-default location (or OPENMW_CONFIG / OPENMW_CONFIG_DIR env vars)
18//! let config = OpenMWConfiguration::from_env()?;
19//!
20//! // Iterate content files in load order
21//! for plugin in config.content_files_iter() {
22//!     println!("{}", plugin.value());
23//! }
24//! # Ok::<(), openmw_config::ConfigError>(())
25//! ```
26//!
27//! # Configuration sources
28//!
29//! See the [OpenMW path documentation](https://openmw.readthedocs.io/en/latest/reference/modding/paths.html)
30//! for platform-specific default locations.  The environment variables `OPENMW_CONFIG` (path to
31//! an `openmw.cfg` file) and `OPENMW_CONFIG_DIR` (directory containing `openmw.cfg`) override the
32//! platform default.
33
34mod config;
35#[cfg(feature = "lua")]
36pub mod lua;
37mod platform_paths;
38
39pub use config::{
40    ConfigChainEntry, ConfigChainStatus, OpenMWConfiguration,
41    directorysetting::DirectorySetting,
42    encodingsetting::{EncodingSetting, EncodingType},
43    error::ConfigError,
44    filesetting::FileSetting,
45    gamesetting::GameSettingType,
46    genericsetting::GenericSetting,
47};
48
49#[cfg(feature = "lua")]
50pub use lua::create_lua_module;
51
52pub(crate) trait GameSetting: std::fmt::Display {
53    fn meta(&self) -> &GameSettingMeta;
54}
55
56/// Source-tracking metadata attached to every setting value.
57///
58/// Records which config file defined the setting and any comment lines that
59/// immediately preceded it in the file, so that [`OpenMWConfiguration`]'s
60/// `Display` implementation can round-trip comments faithfully.
61#[derive(Debug, Clone, Eq, PartialEq)]
62pub struct GameSettingMeta {
63    source_config: std::path::PathBuf,
64    comment: String,
65}
66
67impl GameSettingMeta {
68    #[must_use]
69    pub fn source_config(&self) -> &std::path::Path {
70        &self.source_config
71    }
72
73    #[must_use]
74    pub fn comment(&self) -> &str {
75        &self.comment
76    }
77}
78
79const NO_CONFIG_DIR: &str = "FAILURE: COULD NOT READ CONFIG DIRECTORY";
80const NO_LOCAL_DIR: &str = "FAILURE: COULD NOT READ LOCAL DIRECTORY";
81const NO_GLOBAL_DIR: &str = "FAILURE: COULD NOT READ GLOBAL DIRECTORY";
82const DEFAULT_FLATPAK_APP_ID: &str = "org.openmw.OpenMW";
83
84fn has_flatpak_info_file() -> bool {
85    use std::sync::OnceLock;
86
87    static HAS_FLATPAK_INFO: OnceLock<bool> = OnceLock::new();
88    *HAS_FLATPAK_INFO.get_or_init(|| std::path::Path::new("/.flatpak-info").exists())
89}
90
91fn flatpak_mode_enabled() -> bool {
92    #[cfg(not(target_os = "linux"))]
93    {
94        return false;
95    }
96
97    #[cfg(target_os = "linux")]
98    {
99        if std::env::var_os("OPENMW_CONFIG_USING_FLATPAK").is_some() {
100            return true;
101        }
102
103        std::env::var_os("FLATPAK_ID").is_some() || has_flatpak_info_file()
104    }
105}
106
107fn flatpak_app_id() -> String {
108    std::env::var("OPENMW_FLATPAK_ID")
109        .ok()
110        .filter(|value| !value.trim().is_empty())
111        .or_else(|| {
112            std::env::var("FLATPAK_ID")
113                .ok()
114                .filter(|value| !value.trim().is_empty())
115        })
116        .unwrap_or_else(|| DEFAULT_FLATPAK_APP_ID.to_string())
117}
118
119fn flatpak_userconfig_path() -> Result<std::path::PathBuf, ConfigError> {
120    platform_paths::home_dir().map(|home| {
121        home.join(".var")
122            .join("app")
123            .join(flatpak_app_id())
124            .join("config")
125            .join("openmw")
126    })
127}
128
129fn flatpak_userdata_path() -> Result<std::path::PathBuf, ConfigError> {
130    platform_paths::home_dir().map(|home| {
131        home.join(".var")
132            .join("app")
133            .join(flatpak_app_id())
134            .join("data")
135            .join("openmw")
136    })
137}
138
139/// Fallible variant of [`default_config_path`].
140///
141/// Resolution precedence:
142/// 1. Flatpak mode path (`$HOME/.var/app/<app-id>/config/openmw`) when Flatpak mode is enabled.
143/// 2. Platform default path from platform-specific resolvers.
144///
145/// On Linux, Flatpak mode is enabled when `OPENMW_CONFIG_USING_FLATPAK` is set to any value, or
146/// auto-detected via `FLATPAK_ID` / `/.flatpak-info`.
147///
148/// # Errors
149/// Returns [`ConfigError::PlatformPathUnavailable`] if no platform config directory can be discovered.
150pub fn try_default_config_path() -> Result<std::path::PathBuf, ConfigError> {
151    #[cfg(target_os = "android")]
152    return Ok(std::path::PathBuf::from(
153        "/storage/emulated/0/Alpha3/config",
154    ));
155
156    #[cfg(not(target_os = "android"))]
157    {
158        if flatpak_mode_enabled() {
159            return flatpak_userconfig_path();
160        }
161
162        platform_paths::config_dir().map_err(|_| ConfigError::PlatformPathUnavailable("config"))
163    }
164}
165
166/// Path to input bindings and core configuration
167/// These functions are not expected to fail and should they fail, indicate either:
168/// a severe issue with the system
169/// or that an unsupported system is being used.
170///
171/// # Panics
172/// Panics if the platform config directory cannot be determined (unsupported system).
173#[must_use]
174pub fn default_config_path() -> std::path::PathBuf {
175    try_default_config_path().expect(NO_CONFIG_DIR)
176}
177
178/// Fallible variant of [`default_userdata_path`].
179///
180/// Resolution precedence:
181/// 1. Flatpak mode path (`$HOME/.var/app/<app-id>/data/openmw`) when Flatpak mode is enabled.
182/// 2. Platform default path from platform-specific resolvers.
183///
184/// On Linux, Flatpak mode is enabled when `OPENMW_CONFIG_USING_FLATPAK` is set to any value, or
185/// auto-detected via `FLATPAK_ID` / `/.flatpak-info`.
186///
187/// # Errors
188/// Returns [`ConfigError::PlatformPathUnavailable`] if no platform userdata directory can be discovered.
189pub fn try_default_userdata_path() -> Result<std::path::PathBuf, ConfigError> {
190    #[cfg(target_os = "android")]
191    return Ok(std::path::PathBuf::from("/storage/emulated/0/Alpha3"));
192
193    #[cfg(not(target_os = "android"))]
194    {
195        if flatpak_mode_enabled() {
196            return flatpak_userdata_path();
197        }
198
199        platform_paths::data_dir().map_err(|_| ConfigError::PlatformPathUnavailable("userdata"))
200    }
201}
202
203/// Path to save storage, screenshots, navmeshdb, and data-local
204/// These functions are not expected to fail and should they fail, indicate either:
205/// a severe issue with the system
206/// or that an unsupported system is being used.
207///
208/// # Panics
209/// Panics if the platform data directory cannot be determined (unsupported system).
210#[must_use]
211pub fn default_userdata_path() -> std::path::PathBuf {
212    try_default_userdata_path().expect("FAILURE: COULD NOT READ USERDATA DIRECTORY")
213}
214
215/// Path to the `data-local` directory as defined by the engine's defaults.
216///
217/// This directory is loaded last and therefore overrides all other data sources
218/// in the VFS load order.
219#[must_use]
220pub fn default_data_local_path() -> std::path::PathBuf {
221    default_userdata_path().join("data")
222}
223
224/// Fallible variant of [`default_local_path`].
225///
226/// Resolves the `?local?` token target.
227///
228/// - On macOS app bundles, this is the `Contents/Resources` directory.
229/// - On other platforms, this is the directory containing the running executable.
230///
231/// # Errors
232/// Returns [`ConfigError::PlatformPathUnavailable`] if the local path cannot be determined.
233pub fn try_default_local_path() -> Result<std::path::PathBuf, ConfigError> {
234    let exe = std::env::current_exe()?;
235
236    #[cfg(target_os = "macos")]
237    {
238        if let Some(macos_dir) = exe.parent()
239            && macos_dir.file_name() == Some(std::ffi::OsStr::new("MacOS"))
240            && let Some(contents_dir) = macos_dir.parent()
241            && contents_dir.file_name() == Some(std::ffi::OsStr::new("Contents"))
242        {
243            return Ok(contents_dir.join("Resources"));
244        }
245    }
246
247    exe.parent()
248        .map(std::path::Path::to_path_buf)
249        .ok_or(ConfigError::PlatformPathUnavailable("local"))
250}
251
252/// Path that backs the `?local?` token.
253///
254/// # Panics
255/// Panics if the local path cannot be determined.
256#[must_use]
257pub fn default_local_path() -> std::path::PathBuf {
258    try_default_local_path().expect(NO_LOCAL_DIR)
259}
260
261/// Fallible variant of [`default_global_path`].
262///
263/// Resolves the `?global?` token target.
264///
265/// Resolution order:
266/// 1. `OPENMW_GLOBAL_PATH` when set.
267/// 2. Flatpak default (`/app/share/games`) when Flatpak mode is active.
268/// 3. Platform default (`/usr/share/games` on Unix-like systems, `/Library/Application Support` on macOS).
269///
270/// Flatpak app id selection is: `OPENMW_FLATPAK_ID` > `FLATPAK_ID` > `org.openmw.OpenMW`.
271///
272/// # Errors
273/// Returns [`ConfigError::PlatformPathUnavailable`] on unsupported platforms.
274pub fn try_default_global_path() -> Result<std::path::PathBuf, ConfigError> {
275    if let Ok(value) = std::env::var("OPENMW_GLOBAL_PATH")
276        && !value.trim().is_empty()
277    {
278        return Ok(std::path::PathBuf::from(value));
279    }
280
281    if cfg!(windows) {
282        return Err(ConfigError::PlatformPathUnavailable("global"));
283    }
284
285    // NOTE: Flatpak path behavior is intentionally Linux-only.
286    // We are not fully certain whether OpenMW Flatpak builds should prefer a global
287    // or local config path in all packaging variants, so we keep this conservative:
288    // only Linux Flatpak mode maps ?global? to /app/share/games.
289    if flatpak_mode_enabled() {
290        return Ok(std::path::PathBuf::from("/app/share/games"));
291    }
292
293    if cfg!(target_os = "macos") {
294        return Ok(std::path::PathBuf::from("/Library/Application Support"));
295    }
296
297    Ok(std::path::PathBuf::from("/usr/share/games"))
298}
299
300/// Path that backs the `?global?` token.
301///
302/// # Panics
303/// Panics if the global path cannot be determined.
304#[must_use]
305pub fn default_global_path() -> std::path::PathBuf {
306    try_default_global_path().expect(NO_GLOBAL_DIR)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::ffi::OsString;
313    use std::sync::Mutex;
314
315    static ENV_LOCK: Mutex<()> = Mutex::new(());
316
317    fn snapshot_env(keys: &[&str]) -> Vec<(String, Option<OsString>)> {
318        keys.iter()
319            .map(|key| ((*key).to_string(), std::env::var_os(key)))
320            .collect()
321    }
322
323    fn restore_env(snapshot: Vec<(String, Option<OsString>)>) {
324        for (key, value) in snapshot {
325            // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
326            unsafe {
327                if let Some(value) = value {
328                    std::env::set_var(&key, value);
329                } else {
330                    std::env::remove_var(&key);
331                }
332            }
333        }
334    }
335
336    #[test]
337    fn test_default_data_local_path_is_userdata_data_child() {
338        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
339        let snapshot = snapshot_env(&[
340            "OPENMW_CONFIG_USING_FLATPAK",
341            "OPENMW_FLATPAK_ID",
342            "FLATPAK_ID",
343            "OPENMW_GLOBAL_PATH",
344        ]);
345
346        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
347        unsafe {
348            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
349            std::env::remove_var("OPENMW_FLATPAK_ID");
350            std::env::remove_var("FLATPAK_ID");
351            std::env::remove_var("OPENMW_GLOBAL_PATH");
352        }
353
354        assert_eq!(
355            default_data_local_path(),
356            default_userdata_path().join("data")
357        );
358
359        restore_env(snapshot);
360    }
361
362    #[test]
363    #[cfg(windows)]
364    fn test_windows_default_paths_contract() {
365        let cfg = default_config_path();
366        let cfg_str = cfg.to_string_lossy().to_lowercase();
367        assert!(cfg_str.contains("my games"));
368        assert!(cfg_str.contains("openmw"));
369        assert_eq!(default_userdata_path(), cfg);
370    }
371
372    #[test]
373    fn test_try_default_config_path_returns_path_or_error() {
374        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
375        let snapshot = snapshot_env(&[
376            "OPENMW_CONFIG_USING_FLATPAK",
377            "OPENMW_FLATPAK_ID",
378            "FLATPAK_ID",
379        ]);
380        let _ = try_default_config_path();
381        restore_env(snapshot);
382    }
383
384    #[test]
385    fn test_try_default_local_path_returns_path_or_error() {
386        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
387        let snapshot = snapshot_env(&[
388            "OPENMW_CONFIG_USING_FLATPAK",
389            "OPENMW_FLATPAK_ID",
390            "FLATPAK_ID",
391        ]);
392        let _ = try_default_local_path();
393        restore_env(snapshot);
394    }
395
396    #[test]
397    #[cfg(target_os = "linux")]
398    fn test_flatpak_env_flag_forces_flatpak_paths() {
399        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
400        let Ok(home) = platform_paths::home_dir() else {
401            return;
402        };
403
404        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
405        unsafe {
406            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "bananas");
407            std::env::remove_var("OPENMW_FLATPAK_ID");
408            std::env::remove_var("FLATPAK_ID");
409        }
410
411        let cfg = try_default_config_path().expect("flatpak config path should resolve");
412        let data = try_default_userdata_path().expect("flatpak userdata path should resolve");
413
414        assert_eq!(
415            cfg,
416            home.join(".var")
417                .join("app")
418                .join(DEFAULT_FLATPAK_APP_ID)
419                .join("config")
420                .join("openmw")
421        );
422        assert_eq!(
423            data,
424            home.join(".var")
425                .join("app")
426                .join(DEFAULT_FLATPAK_APP_ID)
427                .join("data")
428                .join("openmw")
429        );
430
431        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
432        unsafe {
433            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
434        }
435    }
436
437    #[test]
438    #[cfg(target_os = "linux")]
439    fn test_flatpak_app_id_override_precedence() {
440        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
441        let Ok(home) = platform_paths::home_dir() else {
442            return;
443        };
444
445        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
446        unsafe {
447            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "enabled");
448            std::env::set_var("OPENMW_FLATPAK_ID", "org.example.Override");
449            std::env::set_var("FLATPAK_ID", "org.example.ShouldNotWin");
450        }
451
452        let cfg = try_default_config_path().expect("flatpak config path should resolve");
453        assert_eq!(
454            cfg,
455            home.join(".var")
456                .join("app")
457                .join("org.example.Override")
458                .join("config")
459                .join("openmw")
460        );
461
462        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
463        unsafe {
464            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
465            std::env::remove_var("OPENMW_FLATPAK_ID");
466            std::env::remove_var("FLATPAK_ID");
467        }
468    }
469
470    #[test]
471    #[cfg(target_os = "linux")]
472    fn test_flatpak_auto_detect_via_flatpak_id() {
473        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
474        let Ok(home) = platform_paths::home_dir() else {
475            return;
476        };
477
478        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
479        unsafe {
480            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
481            std::env::remove_var("OPENMW_FLATPAK_ID");
482            std::env::set_var("FLATPAK_ID", "org.example.AutoDetect");
483        }
484
485        let data = try_default_userdata_path().expect("flatpak userdata path should resolve");
486        assert_eq!(
487            data,
488            home.join(".var")
489                .join("app")
490                .join("org.example.AutoDetect")
491                .join("data")
492                .join("openmw")
493        );
494
495        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
496        unsafe {
497            std::env::remove_var("FLATPAK_ID");
498        }
499    }
500
501    #[test]
502    fn test_global_path_env_override_has_precedence() {
503        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
504        let expected = std::path::PathBuf::from("/opt/openmw/global");
505
506        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
507        unsafe {
508            std::env::set_var("OPENMW_GLOBAL_PATH", expected.as_os_str());
509        }
510
511        assert_eq!(
512            try_default_global_path().expect("global override should be used"),
513            expected
514        );
515
516        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
517        unsafe {
518            std::env::remove_var("OPENMW_GLOBAL_PATH");
519        }
520    }
521
522    #[test]
523    #[cfg(not(windows))]
524    fn test_global_path_default_is_platform_or_flatpak_value() {
525        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
526
527        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
528        unsafe {
529            std::env::remove_var("OPENMW_GLOBAL_PATH");
530            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
531            std::env::remove_var("FLATPAK_ID");
532        }
533
534        if cfg!(target_os = "macos") {
535            assert_eq!(
536                try_default_global_path().expect("macOS global path should resolve"),
537                std::path::PathBuf::from("/Library/Application Support")
538            );
539        } else if flatpak_mode_enabled() {
540            assert_eq!(
541                try_default_global_path().expect("flatpak global path should resolve"),
542                std::path::PathBuf::from("/app/share/games")
543            );
544        } else {
545            assert_eq!(
546                try_default_global_path().expect("unix global path should resolve"),
547                std::path::PathBuf::from("/usr/share/games")
548            );
549        }
550    }
551
552    #[test]
553    #[cfg(windows)]
554    fn test_global_path_is_unavailable_on_windows_without_override() {
555        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
556
557        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
558        unsafe {
559            std::env::remove_var("OPENMW_GLOBAL_PATH");
560            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
561            std::env::remove_var("FLATPAK_ID");
562        }
563
564        assert!(matches!(
565            try_default_global_path(),
566            Err(ConfigError::PlatformPathUnavailable("global"))
567        ));
568    }
569
570    #[test]
571    #[cfg(not(target_os = "linux"))]
572    fn test_flatpak_mode_is_ignored_off_linux() {
573        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
574
575        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
576        unsafe {
577            std::env::set_var("OPENMW_CONFIG_USING_FLATPAK", "1");
578            std::env::set_var("FLATPAK_ID", "org.example.Flatpak");
579            std::env::remove_var("OPENMW_GLOBAL_PATH");
580        }
581
582        assert!(!flatpak_mode_enabled());
583
584        assert_eq!(
585            try_default_config_path().ok(),
586            platform_paths::config_dir().ok()
587        );
588        assert_eq!(
589            try_default_userdata_path().ok(),
590            platform_paths::data_dir().ok()
591        );
592
593        if cfg!(windows) {
594            assert!(matches!(
595                try_default_global_path(),
596                Err(ConfigError::PlatformPathUnavailable("global"))
597            ));
598        } else if cfg!(target_os = "macos") {
599            assert_eq!(
600                try_default_global_path().expect("macOS global path should resolve"),
601                std::path::PathBuf::from("/Library/Application Support")
602            );
603        }
604
605        // SAFETY: guarded by a process-wide mutex in tests to prevent concurrent env mutation.
606        unsafe {
607            std::env::remove_var("OPENMW_CONFIG_USING_FLATPAK");
608            std::env::remove_var("FLATPAK_ID");
609            std::env::remove_var("OPENMW_GLOBAL_PATH");
610        }
611    }
612}