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