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