1use std::{
104 fmt::Debug,
105 marker::PhantomData,
106 path::{Path, PathBuf},
107 sync::{Mutex, mpsc},
108 thread,
109};
110
111use bevy::prelude::*;
112use directories::ProjectDirs;
113use serde::{Deserialize, Serialize};
114use thiserror::Error;
115
116#[derive(Error, Debug)]
121pub enum SettingsError {
122 #[error("IO error: {0}")]
123 Io(#[from] std::io::Error),
124 #[error("Serialization error: {0}")]
125 Serialize(String),
126 #[error("Deserialization error: {0}")]
127 Deserialize(String),
128}
129
130pub type SettingsResult<T> = Result<T, SettingsError>;
131
132pub trait SettingsFormat: Send + Sync + 'static {
137 fn file_extension() -> &'static str;
138 fn serialize<T: Serialize>(value: &T) -> SettingsResult<String>;
139 fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T>;
140}
141
142pub struct TomlFormat;
143impl SettingsFormat for TomlFormat {
144 fn file_extension() -> &'static str {
145 "toml"
146 }
147 fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
148 toml::to_string(value).map_err(|e| SettingsError::Serialize(e.to_string()))
149 }
150 fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
151 toml::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
152 }
153}
154
155pub struct JsonFormat;
156impl SettingsFormat for JsonFormat {
157 fn file_extension() -> &'static str {
158 "json"
159 }
160 fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
161 serde_json::to_string_pretty(value).map_err(|e| SettingsError::Serialize(e.to_string()))
162 }
163 fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
164 serde_json::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
165 }
166}
167
168fn write_binary<T: Serialize>(path: &Path, value: &T) -> SettingsResult<()> {
173 let bytes =
174 postcard::to_allocvec(value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
175 std::fs::write(path, bytes).map_err(SettingsError::Io)
176}
177
178fn read_binary<T: for<'de> Deserialize<'de>>(path: &Path) -> SettingsResult<T> {
179 let bytes = std::fs::read(path).map_err(SettingsError::Io)?;
180 postcard::from_bytes(&bytes).map_err(|e| SettingsError::Deserialize(e.to_string()))
181}
182
183pub trait Setting:
188 Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
189{
190}
191impl<T> Setting for T where
192 T: Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
193{
194}
195
196pub trait ValidatedSetting {
202 fn validate(&mut self);
203}
204
205#[derive(Clone, Copy, PartialEq, Eq)]
211pub enum SettingsStorage {
212 SystemConfigDir,
217 GameLocalDir,
219}
220
221#[derive(Clone)]
222pub struct SettingsPluginConfig {
223 pub domain: String,
224 pub company: String,
225 pub project: String,
226 pub format: FormatKind,
227 pub file_name: Option<String>,
228 pub storage: SettingsStorage,
229}
230
231#[derive(Clone, Copy, PartialEq, Eq)]
232pub enum FormatKind {
233 Toml,
234 Json,
235 Binary,
236}
237
238impl SettingsPluginConfig {
239 pub fn validate(&self) {
243 match self.storage {
244 SettingsStorage::SystemConfigDir => {
245 if self.company.is_empty() {
246 panic!(
247 "SettingsPluginConfig: 'company' cannot be empty when using SystemConfigDir. \
248 Please set a valid company name (e.g., 'MyCompany')."
249 );
250 }
251 if self.project.is_empty() {
252 panic!(
253 "SettingsPluginConfig: 'project' cannot be empty when using SystemConfigDir. \
254 Please set a valid project name (e.g., 'MyGame')."
255 );
256 }
257 if ProjectDirs::from(&self.domain, &self.company, &self.project).is_none() {
258 panic!(
259 "SettingsPluginConfig: unable to determine standard config directory for domain='{}', company='{}', project='{}'. \
260 Check that the strings do not contain invalid characters (e.g., '/', '\\', ':' on Windows).",
261 self.domain, self.company, self.project
262 );
263 }
264 }
265 SettingsStorage::GameLocalDir => {
266 if self.company.is_empty() || self.project.is_empty() {
269 bevy::log::warn!(
270 "SettingsPluginConfig: 'company' or 'project' is empty while using GameLocalDir. \
271 These fields are not required for local storage but may affect compatibility."
272 );
273 }
274 }
275 }
276 }
277}
278
279impl Default for SettingsPluginConfig {
280 fn default() -> Self {
281 Self {
282 domain: "com".into(),
283 company: "".into(),
284 project: "".into(),
285 format: FormatKind::Toml,
286 file_name: None,
287 storage: SettingsStorage::SystemConfigDir,
288 }
289 }
290}
291
292#[derive(Event)]
297pub struct PersistAllSettings;
298
299#[derive(Event)]
300pub struct PersistSetting<S: Setting> {
301 pub value: Option<S>,
302}
303
304#[derive(Event)]
305pub struct ReloadSetting<S: Setting> {
306 pub _phantom: PhantomData<S>,
307}
308
309#[derive(Event)]
311pub struct SettingsSaveError<S: Setting> {
312 pub error: SettingsError,
313 pub _phantom: PhantomData<S>,
314}
315
316#[derive(Resource)]
321struct SettingsInternal<S: Setting> {
322 config: SettingsPluginConfig,
323 path: PathBuf,
324 temp_path: PathBuf,
325 directory: PathBuf,
326 error_sender: mpsc::Sender<SettingsError>,
327 _marker: PhantomData<S>,
328}
329
330#[derive(Resource)]
332struct SettingsErrorReceiver<S: Setting> {
333 receiver: Mutex<mpsc::Receiver<SettingsError>>,
334 _marker: PhantomData<S>,
335}
336
337impl<S: Setting> SettingsInternal<S> {
338 fn new(
339 config: SettingsPluginConfig,
340 dir: PathBuf,
341 path: PathBuf,
342 error_sender: mpsc::Sender<SettingsError>,
343 ) -> Self {
344 let extension = match config.format {
345 FormatKind::Toml => TomlFormat::file_extension(),
346 FormatKind::Json => JsonFormat::file_extension(),
347 FormatKind::Binary => "bin",
348 };
349 Self {
350 temp_path: path.with_extension(format!("tmp.{}", extension)),
351 directory: dir,
352 path,
353 config,
354 error_sender,
355 _marker: PhantomData,
356 }
357 }
358}
359
360pub struct SettingsPlugin<S> {
365 config: SettingsPluginConfig,
366 _marker: PhantomData<S>,
367}
368
369impl<S: Setting + ValidatedSetting> SettingsPlugin<S> {
370 pub fn from_config(config: SettingsPluginConfig) -> Self {
374 config.validate();
375 Self {
376 config,
377 _marker: PhantomData,
378 }
379 }
380
381 fn file_stem(&self) -> String {
383 if let Some(ref name) = self.config.file_name {
384 name.clone()
385 } else {
386 let type_name = std::any::type_name::<S>();
388 let short_name = type_name.split("::").last().unwrap_or(type_name);
389 let mut snake = String::new();
390 for (i, ch) in short_name.chars().enumerate() {
391 if ch.is_uppercase() && i > 0 {
392 snake.push('_');
393 snake.push(ch.to_ascii_lowercase());
394 } else {
395 snake.push(ch.to_ascii_lowercase());
396 }
397 }
398 snake
399 }
400 }
401
402 fn base_dir(&self) -> PathBuf {
404 match self.config.storage {
405 SettingsStorage::SystemConfigDir => {
406 let proj_dirs = ProjectDirs::from(
407 &self.config.domain,
408 &self.config.company,
409 &self.config.project,
410 )
411 .expect("Already validated in config");
412 proj_dirs.config_dir().to_path_buf()
413 }
414 SettingsStorage::GameLocalDir => {
415 let exe_path =
416 std::env::current_exe().expect("Failed to get current executable path");
417 exe_path
418 .parent()
419 .expect("Executable has no parent directory")
420 .to_path_buf()
421 }
422 }
423 }
424
425 fn load(&self) -> SettingsResult<S> {
426 let path = self.path();
427 let mut settings = if !path.exists() {
428 S::default()
429 } else {
430 match self.config.format {
431 FormatKind::Binary => read_binary(&path)?,
432 _ => {
433 let data = std::fs::read_to_string(&path).map_err(SettingsError::Io)?;
434 self.deserialize_text(&data)?
435 }
436 }
437 };
438 settings.validate();
440 Ok(settings)
441 }
442
443 fn deserialize_text(&self, data: &str) -> SettingsResult<S> {
444 match self.config.format {
445 FormatKind::Toml => TomlFormat::deserialize(data),
446 FormatKind::Json => JsonFormat::deserialize(data),
447 FormatKind::Binary => unreachable!(),
448 }
449 }
450
451 fn serialize_text(&self, value: &S) -> SettingsResult<String> {
452 match self.config.format {
453 FormatKind::Toml => TomlFormat::serialize(value),
454 FormatKind::Json => JsonFormat::serialize(value),
455 FormatKind::Binary => unreachable!(),
456 }
457 }
458
459 fn path(&self) -> PathBuf {
460 let extension = match self.config.format {
461 FormatKind::Toml => TomlFormat::file_extension(),
462 FormatKind::Json => JsonFormat::file_extension(),
463 FormatKind::Binary => "bin",
464 };
465 let file_stem = self.file_stem();
466 self.base_dir().join(format!("{}.{}", file_stem, extension))
467 }
468
469 fn directory(&self) -> PathBuf {
470 self.base_dir()
471 }
472
473 fn save_to_file(
475 temp_path: &Path,
476 path: &Path,
477 settings: &S,
478 format_kind: FormatKind,
479 error_sender: mpsc::Sender<SettingsError>,
480 ) {
481 let write_result = match format_kind {
482 FormatKind::Binary => write_binary(temp_path, settings),
483 _ => {
484 let content = match format_kind {
485 FormatKind::Toml => TomlFormat::serialize(settings),
486 FormatKind::Json => JsonFormat::serialize(settings),
487 _ => unreachable!(),
488 };
489 match content {
490 Ok(c) => std::fs::write(temp_path, c).map_err(SettingsError::Io),
491 Err(e) => Err(e),
492 }
493 }
494 };
495
496 match write_result {
497 Ok(_) => {
498 if let Err(e) = std::fs::rename(temp_path, path) {
499 bevy::log::error!("Failed to rename settings file: {}", e);
500 let _ = error_sender.send(SettingsError::Io(e));
501 let _ = std::fs::remove_file(temp_path);
503 } else {
504 bevy::log::debug!("Settings saved to {:?}", path);
505 }
506 }
507 Err(e) => {
508 bevy::log::error!("Failed to write temp settings file: {}", e);
509 let _ = error_sender.send(e);
510 let _ = std::fs::remove_file(temp_path);
512 }
513 }
514 }
515
516 fn persist_setting_observer(
519 event: On<PersistSetting<S>>,
520 mut settings: ResMut<S>,
521 internal: Res<SettingsInternal<S>>,
522 ) {
523 let ev = event.event();
524 if let Some(new_value) = &ev.value {
525 *settings = new_value.clone();
526 }
528
529 let path = internal.path.clone();
530 let temp_path = internal.temp_path.clone();
531 let settings_clone = settings.clone();
532 let format_kind = internal.config.format;
533 let error_sender = internal.error_sender.clone();
534
535 thread::Builder::new()
536 .name("bevy-settings-save".into())
537 .spawn(move || {
538 Self::save_to_file(
539 &temp_path,
540 &path,
541 &settings_clone,
542 format_kind,
543 error_sender,
544 );
545 })
546 .expect("Failed to spawn save thread");
547 }
548
549 fn persist_all_observer(
550 _event: On<PersistAllSettings>,
551 settings: Res<S>,
552 internal: Res<SettingsInternal<S>>,
553 ) {
554 let path = internal.path.clone();
555 let temp_path = internal.temp_path.clone();
556 let settings_clone = settings.clone();
557 let format_kind = internal.config.format;
558 let error_sender = internal.error_sender.clone();
559
560 thread::Builder::new()
561 .name("bevy-settings-save".into())
562 .spawn(move || {
563 Self::save_to_file(
564 &temp_path,
565 &path,
566 &settings_clone,
567 format_kind,
568 error_sender,
569 );
570 })
571 .expect("Failed to spawn save thread");
572 }
573
574 fn reload_observer(
575 _event: On<ReloadSetting<S>>,
576 mut settings: ResMut<S>,
577 internal: Res<SettingsInternal<S>>,
578 ) {
579 let load_result = match internal.config.format {
580 FormatKind::Binary => read_binary::<S>(&internal.path),
581 _ => {
582 let content = match std::fs::read_to_string(&internal.path) {
583 Ok(c) => c,
584 Err(e) => {
585 bevy::log::error!("Failed to read settings file: {}", e);
586 return;
587 }
588 };
589 match internal.config.format {
590 FormatKind::Toml => TomlFormat::deserialize(&content),
591 FormatKind::Json => JsonFormat::deserialize(&content),
592 _ => unreachable!(),
593 }
594 }
595 };
596 match load_result {
597 Ok(mut new_settings) => {
598 new_settings.validate(); *settings = new_settings;
600 bevy::log::info!("Settings reloaded from {:?}", internal.path);
601 }
602 Err(e) => {
603 bevy::log::error!("Failed to reload settings: {}", e);
604 }
605 }
606 }
607
608 fn process_error_messages(
610 error_receiver: ResMut<SettingsErrorReceiver<S>>,
611 mut commands: Commands,
612 ) {
613 let receiver = error_receiver.receiver.lock().unwrap();
615 while let Ok(error) = receiver.try_recv() {
616 commands.trigger(SettingsSaveError::<S> {
617 error,
618 _phantom: PhantomData::<S>,
619 });
620 }
621 }
622}
623
624impl<S: Setting + ValidatedSetting> Plugin for SettingsPlugin<S> {
625 fn build(&self, app: &mut App) {
626 let load_result = self.load();
627 let mut initial_value = match load_result {
628 Ok(v) => v,
629 Err(e) => {
630 bevy::log::error!(
631 "Failed to load settings for {}: {}, using default",
632 std::any::type_name::<S>(),
633 e
634 );
635 S::default()
636 }
637 };
638 initial_value.validate();
640
641 let dir = self.directory();
642 let path = self.path();
643
644 let (error_sender, error_receiver) = mpsc::channel();
646
647 if let Err(e) = std::fs::create_dir_all(&dir) {
649 bevy::log::error!("Failed to create settings directory: {}", e);
650 let _ = error_sender.send(SettingsError::Io(e));
651 }
652
653 let internal = SettingsInternal::<S>::new(self.config.clone(), dir, path, error_sender);
654 let error_receiver_resource = SettingsErrorReceiver::<S> {
655 receiver: Mutex::new(error_receiver),
656 _marker: PhantomData,
657 };
658
659 app.insert_resource(initial_value)
660 .insert_resource(internal)
661 .insert_resource(error_receiver_resource)
662 .add_observer(Self::persist_setting_observer)
663 .add_observer(Self::persist_all_observer)
664 .add_observer(Self::reload_observer)
665 .add_systems(Update, Self::process_error_messages);
666 }
667}
668
669#[cfg(test)]
674mod test_utils {
675 use super::*;
676 use std::path::PathBuf;
677
678 pub fn should_cleanup() -> bool {
681 std::env::var("KEEP_TEST_FILES").is_err()
682 }
683
684 pub fn cleanup_paths(paths: &[PathBuf]) {
686 if !should_cleanup() {
687 println!("Skipping cleanup due to KEEP_TEST_FILES");
688 return;
689 }
690 for path in paths {
691 if path.exists() {
692 if path.is_file() {
693 let _ = std::fs::remove_file(path);
694 } else if path.is_dir() {
695 let _ = std::fs::remove_dir_all(path);
696 }
697 }
698 }
699 }
700
701 pub fn config_dir(config: &SettingsPluginConfig) -> PathBuf {
705 match config.storage {
706 SettingsStorage::SystemConfigDir => {
707 ProjectDirs::from(&config.domain, &config.company, &config.project)
708 .expect("Failed to determine config directory for test - check domain, company, and project values")
709 .config_dir()
710 .to_path_buf()
711 }
712 SettingsStorage::GameLocalDir => {
713 std::env::temp_dir()
715 }
716 }
717 }
718
719 pub fn settings_path<S: Setting + ValidatedSetting>(config: &SettingsPluginConfig) -> PathBuf {
720 let plugin = SettingsPlugin::<S>::from_config(config.clone());
721 plugin.path()
722 }
723}
724
725#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::test_utils::{cleanup_paths, config_dir, settings_path};
733 use bevy::app::App;
734 use serial_test::serial;
735 use std::collections::HashMap;
736 use std::time::Duration;
737
738 const TEST_DOMAIN: &str = "com";
739 const TEST_COMPANY: &str = "MyCompany";
740 const TEST_PROJECT: &str = "mygame";
741
742 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
743 pub enum GraphicsQuality {
744 #[default]
745 Low,
746 Medium,
747 High,
748 Ultra,
749 }
750
751 #[derive(Resource, Serialize, Deserialize, Clone, Debug, PartialEq)]
752 struct GameConfig {
753 pub render_scale: f32,
754 pub max_fps: u16,
755 pub shadow_map_size: u32,
756 pub anisotropic_filtering: i32,
757 pub vsync_enabled: bool,
758 pub quality: GraphicsQuality,
759 pub default_language: String,
760 pub enabled_post_effects: Vec<String>,
761 pub custom_resolution: Option<(u32, u32)>,
762 pub texture_quality_overrides: HashMap<String, u8>,
763 }
764
765 impl Default for GameConfig {
766 fn default() -> Self {
767 Self {
768 render_scale: 1.0,
769 max_fps: 144,
770 shadow_map_size: 2048,
771 anisotropic_filtering: 16,
772 vsync_enabled: true,
773 quality: GraphicsQuality::High,
774 default_language: "en-US".to_string(),
775 enabled_post_effects: vec!["bloom".to_string(), "ssao".to_string()],
776 custom_resolution: None,
777 texture_quality_overrides: HashMap::new(),
778 }
779 }
780 }
781
782 impl ValidatedSetting for GameConfig {
783 fn validate(&mut self) {
784 self.render_scale = self.render_scale.clamp(0.5, 2.0);
785 self.max_fps = self.max_fps.clamp(30, 360);
786 self.shadow_map_size = self.shadow_map_size.clamp(512, 4096);
787 self.anisotropic_filtering = self.anisotropic_filtering.clamp(1, 16);
788 if self.default_language.is_empty() {
789 self.default_language = "en-US".to_string();
790 }
791 self.enabled_post_effects
792 .retain(|effect| matches!(effect.as_str(), "bloom" | "ssao" | "motion_blur"));
793 if let Some((w, h)) = self.custom_resolution {
794 if w == 0 || h == 0 {
795 self.custom_resolution = None;
796 }
797 }
798 for (_, quality) in self.texture_quality_overrides.iter_mut() {
799 *quality = (*quality).clamp(0, 100);
800 }
801 }
802 }
803
804 #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
805 struct TestPreferences {
806 pub volume: f32,
807 pub size: f32,
808 }
809
810 impl ValidatedSetting for TestPreferences {
812 fn validate(&mut self) {}
813 }
814
815 #[test]
816 #[serial]
817 fn test_automatic_file_name() {
818 let mut app = App::new();
819
820 let base_config = SettingsPluginConfig {
821 domain: TEST_DOMAIN.into(),
822 company: TEST_COMPANY.into(),
823 project: TEST_PROJECT.into(),
824 format: FormatKind::Toml,
825 file_name: None,
826 storage: SettingsStorage::SystemConfigDir,
827 };
828
829 app.add_plugins(SettingsPlugin::<GameConfig>::from_config(
830 base_config.clone(),
831 ));
832 app.add_plugins(SettingsPlugin::<TestPreferences>::from_config(
833 base_config.clone(),
834 ));
835 app.update();
836
837 let plugin_game = SettingsPlugin::<GameConfig>::from_config(base_config.clone());
839 let plugin_prefs = SettingsPlugin::<TestPreferences>::from_config(base_config.clone());
840 assert_eq!(plugin_game.file_stem(), "game_config");
841 assert_eq!(plugin_prefs.file_stem(), "test_preferences");
842
843 {
845 let mut game = app.world_mut().resource_mut::<GameConfig>();
846 game.render_scale = 1.2;
847 game.vsync_enabled = true;
848 }
849 {
850 let mut prefs = app.world_mut().resource_mut::<TestPreferences>();
851 prefs.volume = 0.75;
852 prefs.size = 1.5;
853 }
854
855 app.world_mut()
857 .commands()
858 .trigger(PersistSetting::<GameConfig> { value: None });
859 app.world_mut()
860 .commands()
861 .trigger(PersistSetting::<TestPreferences> { value: None });
862 app.update();
863 std::thread::sleep(Duration::from_millis(100));
864
865 let dir = config_dir(&base_config);
866 let game_path = dir.join("game_config.toml");
867 let prefs_path = dir.join("test_preferences.toml");
868
869 assert!(game_path.exists(), "GameConfig file not found");
870 assert!(prefs_path.exists(), "TestPreferences file not found");
871
872 let game_content = std::fs::read_to_string(&game_path).unwrap();
874 let loaded_game: GameConfig = toml::from_str(&game_content).unwrap();
875 assert_eq!(loaded_game.render_scale, 1.2);
876 assert_eq!(loaded_game.vsync_enabled, true);
877
878 let prefs_content = std::fs::read_to_string(&prefs_path).unwrap();
879 let loaded_prefs: TestPreferences = toml::from_str(&prefs_content).unwrap();
880 assert_eq!(loaded_prefs.volume, 0.75);
881 assert_eq!(loaded_prefs.size, 1.5);
882
883 cleanup_paths(&[game_path, prefs_path]);
885 }
886
887 #[test]
888 #[serial]
889 fn test_explicit_file_name() {
890 let mut app = App::new();
891 let explicit_name = "explicit_name";
892 let config = SettingsPluginConfig {
893 domain: TEST_DOMAIN.into(),
894 company: TEST_COMPANY.into(),
895 project: TEST_PROJECT.into(),
896 format: FormatKind::Toml,
897 file_name: Some(explicit_name.into()),
898 storage: SettingsStorage::SystemConfigDir,
899 };
900 app.add_plugins(SettingsPlugin::<GameConfig>::from_config(config.clone()));
901 app.update();
902
903 let plugin = SettingsPlugin::<GameConfig>::from_config(config.clone());
904 assert_eq!(plugin.file_stem(), explicit_name);
905
906 {
907 let mut game = app.world_mut().resource_mut::<GameConfig>();
908 game.render_scale = 2.0;
909 }
910 app.world_mut()
911 .commands()
912 .trigger(PersistSetting::<GameConfig> { value: None });
913 app.update();
914 std::thread::sleep(Duration::from_millis(100));
915
916 let path = settings_path::<GameConfig>(&config);
917 assert!(path.exists(), "File does not exist at {:?}", path);
918
919 cleanup_paths(&[path]);
920 }
921}
922
923#[cfg(test)]
924mod comprehensive_tests {
925 use super::*;
926 use crate::test_utils::{cleanup_paths, settings_path};
927 use bevy::app::App;
928 use serial_test::serial;
929 use std::fs;
930 use std::time::Duration;
931
932 const TEST_DOMAIN: &str = "com";
933 const TEST_COMPANY: &str = "MyCompany";
934 const TEST_PROJECT: &str = "mygame";
935
936 #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
937 struct GameConfig2 {
938 pub render_scale: f32,
939 pub vsync: bool,
940 }
941
942 impl ValidatedSetting for GameConfig2 {
943 fn validate(&mut self) {
944 self.render_scale = self.render_scale.clamp(0.5, 2.0);
945 }
946 }
947
948 #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
949 struct UserPrefs2 {
950 pub music_volume: f32,
951 pub sfx_volume: f32,
952 pub controls_inverted: bool,
953 }
954
955 impl ValidatedSetting for UserPrefs2 {
956 fn validate(&mut self) {
957 self.music_volume = self.music_volume.clamp(0.0, 1.0);
958 self.sfx_volume = self.sfx_volume.clamp(0.0, 1.0);
959 }
960 }
961
962 #[test]
963 #[serial]
964 fn test_config_and_prefs_together() {
965 let mut app = App::new();
966
967 let config_game = SettingsPluginConfig {
968 domain: TEST_DOMAIN.into(),
969 company: TEST_COMPANY.into(),
970 project: TEST_PROJECT.into(),
971 format: FormatKind::Toml,
972 file_name: None,
973 storage: SettingsStorage::SystemConfigDir,
974 };
975
976 let user_prefs = SettingsPluginConfig {
977 domain: TEST_DOMAIN.into(),
978 company: TEST_COMPANY.into(),
979 project: TEST_PROJECT.into(),
980 format: FormatKind::Json,
981 file_name: None,
982 storage: SettingsStorage::GameLocalDir,
983 };
984
985 app.add_plugins(SettingsPlugin::<GameConfig2>::from_config(
986 config_game.clone(),
987 ));
988 app.add_plugins(SettingsPlugin::<UserPrefs2>::from_config(
989 user_prefs.clone(),
990 ));
991
992 {
994 let mut config = app.world_mut().resource_mut::<GameConfig2>();
995 config.render_scale = 1.5;
996 config.vsync = true;
997 }
998 {
999 let mut prefs = app.world_mut().resource_mut::<UserPrefs2>();
1000 prefs.music_volume = 0.8;
1001 prefs.sfx_volume = 0.9;
1002 prefs.controls_inverted = true;
1003 }
1004
1005 app.world_mut()
1007 .commands()
1008 .trigger(PersistSetting::<GameConfig2> { value: None });
1009 app.world_mut()
1010 .commands()
1011 .trigger(PersistSetting::<UserPrefs2> { value: None });
1012 for _ in 0..10 {
1013 app.update();
1014 std::thread::sleep(Duration::from_millis(50));
1015 }
1016
1017 let game_path = settings_path::<GameConfig2>(&config_game);
1018 let prefs_path = settings_path::<UserPrefs2>(&user_prefs);
1019
1020 assert!(game_path.exists(), "Config file not found");
1021 assert!(prefs_path.exists(), "Prefs file not found");
1022
1023 let game_content = fs::read_to_string(&game_path).unwrap();
1025 let loaded_config: GameConfig2 = toml::from_str(&game_content).unwrap();
1026 assert_eq!(loaded_config.render_scale, 1.5);
1027 assert_eq!(loaded_config.vsync, true);
1028
1029 let prefs_content = fs::read_to_string(&prefs_path).unwrap();
1030 let loaded_prefs: UserPrefs2 = serde_json::from_str(&prefs_content).unwrap();
1031 assert_eq!(loaded_prefs.music_volume, 0.8);
1032 assert_eq!(loaded_prefs.sfx_volume, 0.9);
1033 assert_eq!(loaded_prefs.controls_inverted, true);
1034
1035 let new_config_content = r#"
1037 render_scale = 10.0
1038 vsync = false
1039 "#;
1040 fs::write(&game_path, new_config_content).unwrap();
1041
1042 let new_prefs_content =
1043 r#"{ "music_volume": 2.0, "sfx_volume": 1.5, "controls_inverted": false }"#;
1044 fs::write(&prefs_path, new_prefs_content).unwrap();
1045
1046 app.world_mut()
1048 .commands()
1049 .trigger(ReloadSetting::<GameConfig2> {
1050 _phantom: PhantomData,
1051 });
1052 app.world_mut()
1053 .commands()
1054 .trigger(ReloadSetting::<UserPrefs2> {
1055 _phantom: PhantomData,
1056 });
1057 for _ in 0..5 {
1058 app.update();
1059 std::thread::sleep(Duration::from_millis(50));
1060 }
1061
1062 let config = app.world().resource::<GameConfig2>();
1063 assert_eq!(config.render_scale, 2.0); assert_eq!(config.vsync, false);
1065
1066 let prefs = app.world().resource::<UserPrefs2>();
1067 assert_eq!(prefs.music_volume, 1.0); assert_eq!(prefs.sfx_volume, 1.0); assert_eq!(prefs.controls_inverted, false);
1070
1071 cleanup_paths(&[game_path, prefs_path]);
1073 }
1074}