1mod 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#[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
152pub 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#[must_use]
187pub fn default_config_path() -> std::path::PathBuf {
188 try_default_config_path().expect(NO_CONFIG_DIR)
189}
190
191pub 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#[must_use]
208pub fn default_user_config_file() -> std::path::PathBuf {
209 try_default_user_config_file().expect(NO_CONFIG_DIR)
210}
211
212pub 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#[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#[must_use]
254pub fn default_data_local_path() -> std::path::PathBuf {
255 default_userdata_path().join("data")
256}
257
258pub 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#[must_use]
291pub fn default_local_path() -> std::path::PathBuf {
292 try_default_local_path().expect(NO_LOCAL_DIR)
293}
294
295pub 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
319pub 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#[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#[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
365pub(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
385pub 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#[must_use]
422pub fn default_global_config_path() -> std::path::PathBuf {
423 try_default_global_config_path().expect(NO_GLOBAL_CONFIG_DIR)
424}
425
426pub 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}