Skip to main content

bevy_settings/
lib.rs

1//! Framework for saving and loading user settings files in Bevy
2//! applications.
3//!
4//! The core of the framework is [`SettingsPlugin`], which
5//! loads and synchronizes settings with the filesystem or browser
6//! local storage, depending on platform.
7//!
8//! Settings are loaded into resources that implement [`SettingsGroup`](trait@SettingsGroup),
9//! which is best implemented using the derive macro [`SettingsGroup`](derive@SettingsGroup).
10//! In addition, the resource must have the `#[reflect(SettingsGroup, Default)]` annotation in
11//! order to be saved and loaded by the plugin.
12//!
13//! Once all these conditions are met, and when [`SettingsPlugin`] is added, systems can query
14//! for settings using [`Res`] and [`ResMut`] like any other resource.
15//!
16//! Refer to [`SettingsPlugin`] for detailed usage information.
17
18use core::any::TypeId;
19use core::time::Duration;
20use serde::ser::SerializeMap;
21use std::collections::HashMap;
22
23use bevy_app::{App, Plugin, PostUpdate};
24use bevy_ecs::{
25    change_detection::Tick,
26    reflect::{AppTypeRegistry, ReflectComponent, ReflectResource},
27    resource::Resource,
28    system::{Command, Commands, Res, ResMut},
29    world::World,
30};
31pub use bevy_ecs_macros::SettingsGroup;
32use bevy_log::warn;
33use bevy_reflect::{
34    enums::DynamicEnum,
35    prelude::ReflectDefault,
36    serde::{ReflectSerializerProcessor, TypedReflectDeserializer, TypedReflectSerializer},
37    CreateTypeData, FromReflect, PartialReflect, Reflect, ReflectMut, TypeInfo, TypePath,
38    TypeRegistration, TypeRegistry,
39};
40
41#[cfg(not(target_arch = "wasm32"))]
42mod store_fs;
43
44#[cfg(target_arch = "wasm32")]
45mod store_wasm;
46
47use bevy_time::{Real, Time, Timer, TimerMode};
48use serde::de::DeserializeSeed;
49#[cfg(not(target_arch = "wasm32"))]
50use store_fs::SettingsStore;
51
52#[cfg(target_arch = "wasm32")]
53use store_wasm::SettingsStore;
54
55/// Plugin to orchestrate loading and saving settings.
56///
57/// When added to an app, `SettingsPlugin` will load settings from storage (either the filesystem
58/// or browser local storage) into resources that implement the [`SettingsGroup`](trait@SettingsGroup),
59/// [`Default`], and [`Reflect`] traits, and, in
60/// addition, are also annotated with `#[reflect(Default, SettingsGroup)]`. The plugin can also be used
61/// to write these settings back to storage after they are changed by sending the [`SaveSettingsDeferred`]
62/// or [`SaveSettingsSync`] commands.
63///
64/// You are required to provide a unique application name, so that your settings don't overwrite
65/// those of other apps. To ensure global uniqueness, it is recommended to use a
66/// [reverse domain name](https://en.wikipedia.org/wiki/Reverse_domain_name_notation),
67/// e.g. "com.example.myapp". The plugin will create a directory with that name in the
68/// appropriate filesystem location (depending on platform) for app settings. For platforms
69/// without filesystems, other storage mechanisms will be used.
70///
71/// If you do not have a domain name and cannot
72/// afford one, use a reverse domain based on the URL of your repo (GitHub, GitLab, Codeberg
73/// and so on).
74///
75/// # Storage location and format
76///
77/// Settings are stored as **TOML** files. Each [`SettingsGroup`] type becomes a top-level section
78/// in the file. The default filename is `settings`, which produces `settings.toml`.
79///
80/// On desktop platforms, files are written to `{preferences_dir()}/{app_name}/{filename}.toml`,
81/// where [`preferences_dir()`](bevy_platform::dirs::preferences_dir) is provided by
82/// [`bevy_platform::dirs::preferences_dir`]. With `app_name = "com.example.myapp"` and the default
83/// filename, typical paths are:
84///
85/// - Linux: `~/.config/com.example.myapp/settings.toml` (or under `$XDG_CONFIG_HOME` when set)
86/// - macOS: `~/Library/Preferences/com.example.myapp/settings.toml`
87/// - Windows: `%LocalAppData%\com.example.myapp\settings.toml`
88///
89/// On `wasm32`, settings are stored in browser `localStorage` under the key
90/// `{app_name}-{filename}` as a TOML string.
91///
92/// A file with one [`SettingsGroup`] named `counter` might look like:
93///
94/// ```toml
95/// [counter]
96/// count = 5
97/// enabled = true
98/// ```
99///
100/// Use `settings_group(file = "...")` on a [`SettingsGroup`] to write to a different file in the
101/// same app directory (see [`SettingsGroup`] for details).
102///
103/// Adding this plugin causes an immediate load of settings (from either the filesystem or
104/// browser local storage, depending on platform).
105///
106/// When using this plugin, care must be taken to ensure that plugins execute in the proper order.
107/// Loading settings causes registered settings to be inserted into the world as bevy resources.
108/// You cannot access these values before they are loaded, but you may want to use the loaded values
109/// when configuring other plugins. For this reason, it's generally a good idea to initialize and
110/// load settings before other plugins. The settings plugin does not depend on any other
111/// plugins.
112///
113/// In many cases, you may want to introduce additional "glue" plugins that copy setting
114/// properties after they are loaded. For example, the
115/// [`WindowPlugin`](https://docs.rs/bevy/latest/bevy/prelude/struct.WindowPlugin.html) plugin knows
116/// nothing about settings, but if you want the window size and position to persist between runs
117/// you can add an additional plugin which copies the window settings from the resource to the
118/// actual window entity.
119///
120/// Saving of settings is not automatic; the recommended practice is to issue a
121/// [`SaveSettingsDeferred`] command after modifying a settings resource. This will wait for
122/// a short interval and then spawn an i/o task to write out the changed settings file. You can
123/// also issue a [`SaveSettingsSync::IfChanged`] command immediately before exiting the app.
124/// Note that on some platforms, depending on how the user exits (such as invoking Command-Q on
125/// ``MacOS``) there may be no opportunity to intercept the app exit event, so the most reliable
126/// approach is to use both techniques: deferred save and save-on-exit.
127///
128/// Saving is crash-resistant: if the app crashes in the middle of a save, the settings file
129/// will not be corrupted (it writes to a temporary file first, then uses atomic operations to
130/// replace the previous file).
131pub struct SettingsPlugin {
132    /// The unique name of the application.
133    pub app_name: String,
134}
135
136impl SettingsPlugin {
137    /// Construct a new `SettingsPlugin` for the given application name.
138    pub fn new(app_name: &str) -> Self {
139        Self {
140            app_name: app_name.to_string(),
141        }
142    }
143}
144
145impl Plugin for SettingsPlugin {
146    fn build(&self, app: &mut App) {
147        let app_name = self.app_name.clone();
148        let world = app.world();
149        let last_save = world.read_change_tick();
150
151        // Get the type registry and clone the Arc so we don't have to worry about borrowing.
152        let Some(app_types) = world.get_resource::<AppTypeRegistry>() else {
153            return;
154        };
155        let app_types = app_types.clone();
156        let types = app_types.read();
157
158        let world = app.world_mut();
159        let file_index = build_settings_registry(&app_name, &types, last_save);
160
161        // Now load each of the toml files we discovered, and apply their properties to
162        // the resources in the world.
163        for (filename, manifest) in file_index.files.iter() {
164            load_settings_file(world, &app_name, filename, manifest, &types);
165        }
166
167        // Cache the index so that we don't have to do it again when saving (and also makes
168        // saving more deterministic).
169        drop(types);
170        world.insert_resource::<SettingsFileRegistry>(file_index);
171
172        app.add_systems(PostUpdate, handle_delayed_save);
173    }
174}
175
176/// Trait which identifies a type as corresponding to a section with a settings file.
177///
178/// In order for [`SettingsPlugin`] to do anything with types that implement this trait, the type must also
179/// be annotated with `#[reflect(SettingsGroup, Default)]`.
180///
181/// You can override the name of the section with `settings_group(group = "<name>")`.
182/// For enum `SettingGroup`s, you can also override the name of its key with `settings_group(key = "<name>")`
183/// The name should be in ``snake_case`` to be consistent with TOML style.
184/// If there is a collision between names (multiple resources have the same name) then
185/// the resulting properties will be merged into a single section.
186///
187/// You can also control which file the type gets saved to via
188/// `settings_group(file = "<filename>")`. This should be the base name of the file without the
189/// extension. The default name is `settings`, which will cause the settings to be written out
190/// to `settings.toml` in the app's settings directory.
191///
192/// Since these resources are loaded from storage, it is possible for them to be modified by hand by users,
193/// so it's important to not rely on the validity of the data. In particular, it is important to ensure you do not
194/// rely on any invariants of the input data to ensure safety elsewhere in your code.
195pub trait SettingsGroup: Resource + Reflect + Default {
196    /// The name of the logical section within the settings file.
197    fn settings_group_name() -> &'static str;
198
199    /// The key name within the settings file.
200    /// For structs, this should be set to `None`; The struct’s field names will be used as keys.
201    /// For enums, the `SettingsGroup` will use this key name within the settings file for its sole key-value pair.
202    /// This is typically the same as the group name, but can be customized.
203    fn settings_key_name() -> Option<&'static str>;
204
205    /// The name of the configuration file that contains this settings group.
206    // TODO: Eventually convert this into an enum which represents various configuration sources.
207    fn settings_source() -> Option<&'static str>;
208}
209
210/// Reflected data from a [`SettingsGroup`].
211#[derive(Clone)]
212pub struct ReflectSettingsGroup {
213    /// The name of the logical section within the settings file.
214    settings_group_name: &'static str,
215    /// The key name within the settings file. Should only be `Some` for enums.
216    settings_key_name: Option<&'static str>,
217    /// The name of the settings file, defaults to "settings".
218    settings_source: Option<&'static str>,
219}
220
221impl ReflectSettingsGroup {
222    /// Returns the groups's name.
223    pub fn settings_group_name(&self) -> &'static str {
224        self.settings_group_name
225    }
226
227    /// Returns the key name within the settings file of this group. Should only be `Some` for enums.
228    pub fn settings_key_name(&self) -> Option<&'static str> {
229        self.settings_key_name
230    }
231
232    /// Returns the name of this group's settings file.
233    pub fn settings_source(&self) -> Option<&'static str> {
234        self.settings_source
235    }
236}
237
238impl<T: SettingsGroup + FromReflect + TypePath> CreateTypeData<T> for ReflectSettingsGroup {
239    fn create_type_data(_input: ()) -> Self {
240        ReflectSettingsGroup {
241            settings_group_name: T::settings_group_name(),
242            settings_key_name: T::settings_key_name(),
243            settings_source: T::settings_source(),
244        }
245    }
246
247    fn insert_dependencies(type_registration: &mut TypeRegistration) {
248        type_registration.register_type_data::<ReflectResource, T>();
249    }
250}
251
252/// List of resource types that will be associated with a specific settings file.
253/// Also tracks when that file was last written or read.
254#[derive(Default)]
255struct SettingsFileManifest {
256    last_save: Tick,
257    resource_types: Vec<TypeId>,
258}
259
260/// Records the game tick when settings were last loaded or saved. This is used to determine
261/// which settings files have changed and need to be saved. Also tracks which settings files
262/// are associated with which resource types.
263#[derive(Resource)]
264struct SettingsFileRegistry {
265    /// App name (from plugin)
266    app_name: String,
267
268    /// List of known settings files, determined by scanning reflection registry.
269    files: HashMap<&'static str, SettingsFileManifest>,
270
271    /// Timer used for batched saving.
272    save_timer: Timer,
273}
274
275/// A Command which saves settings to disk. This blocks the command queue until saving
276/// is complete.
277#[derive(Default, PartialEq)]
278pub enum SaveSettingsSync {
279    /// Save settings only if they have changed since the most recent load or save.
280    #[default]
281    IfChanged,
282    /// Save settings unconditionally.
283    Always,
284}
285
286impl Command for SaveSettingsSync {
287    type Out = ();
288
289    fn apply(self, world: &mut World) {
290        save_settings(world, false, self == SaveSettingsSync::Always);
291    }
292}
293
294/// A [`Command`] which saves settings to disk. Actual file system operations happen in another thread.
295#[derive(Default, PartialEq)]
296pub enum SaveSettings {
297    /// Save settings only if they have changed since the most recent load or save.
298    #[default]
299    IfChanged,
300    /// Save settings unconditionally.
301    Always,
302}
303
304impl Command for SaveSettings {
305    type Out = ();
306
307    fn apply(self, world: &mut World) {
308        save_settings(world, true, self == SaveSettings::Always);
309    }
310}
311
312/// A Command which saves changed settings after a delay. This is debounced: issuing this
313/// command multiple times resets the delay timer each time. This is meant to be used for settings
314/// which change at a high frequency, such as dragging a slider which controls the game's audio
315/// volume. The default delay is 1.0 seconds.
316pub struct SaveSettingsDeferred(pub Duration);
317
318impl Default for SaveSettingsDeferred {
319    fn default() -> Self {
320        Self(Duration::from_secs(1))
321    }
322}
323
324impl Command for SaveSettingsDeferred {
325    type Out = ();
326
327    fn apply(self, world: &mut World) {
328        let Some(mut registry) = world.get_resource_mut::<SettingsFileRegistry>() else {
329            return;
330        };
331
332        registry.save_timer.set_duration(self.0);
333        registry.save_timer.reset();
334        registry.save_timer.unpause();
335    }
336}
337
338fn save_settings(world: &mut World, use_async: bool, force: bool) {
339    let this_run = world.change_tick();
340    let Some(registry) = world.get_resource::<SettingsFileRegistry>() else {
341        warn!("Settings registry not found - did you forget to install the SettingsPlugin?");
342        return;
343    };
344    let Some(app_types) = world.get_resource::<AppTypeRegistry>() else {
345        return;
346    };
347    let app_types = app_types.clone();
348    let types = app_types.read();
349
350    for (filename, manifest) in registry.files.iter() {
351        if force || has_settings_changed(world, manifest) {
352            let table = resources_to_toml(world, &types, manifest);
353            let store = SettingsStore::new(&registry.app_name);
354            if use_async {
355                store.save_async(filename, table);
356            } else {
357                store.save(filename, table);
358            }
359        }
360    }
361
362    // Update timestamps
363    let mut registry = world.get_resource_mut::<SettingsFileRegistry>().unwrap();
364    for manifest in registry.files.values_mut() {
365        manifest.last_save = this_run;
366    }
367}
368
369fn has_settings_changed(world: &World, manifest: &SettingsFileManifest) -> bool {
370    let this_run = world.read_change_tick();
371    manifest.resource_types.iter().any(|r| {
372        let Some(component_id) = world.components().get_id(*r) else {
373            return false;
374        };
375        if let Some(resource_change) = world.get_resource_change_ticks_by_id(component_id) {
376            return resource_change.is_changed(manifest.last_save, this_run);
377        }
378        false
379    })
380}
381
382struct SettingsSerializerProcessor;
383
384impl ReflectSerializerProcessor for SettingsSerializerProcessor {
385    fn try_serialize<S>(
386        &self,
387        value: &dyn PartialReflect,
388        _registry: &TypeRegistry,
389        serializer: S,
390    ) -> Result<Result<S::Ok, S>, S::Error>
391    where
392        S: serde::Serializer,
393    {
394        let Some(type_info) = value.get_represented_type_info() else {
395            return Ok(Err(serializer));
396        };
397
398        let is_option = type_info.type_path_table().module_path() == Some("core::option")
399            && type_info.type_path_table().ident() == Some("Option");
400
401        let is_none = is_option
402            && value
403                .reflect_ref()
404                .as_enum()
405                .is_ok_and(|enum_value| enum_value.variant_name() == "None");
406
407        // If the value is None, serialize as an empty map
408        if is_none {
409            let map = serializer.serialize_map(Some(0))?;
410            return Ok(Ok(SerializeMap::end(map)?));
411        }
412
413        Ok(Err(serializer))
414    }
415}
416
417fn resources_to_toml(
418    world: &World,
419    types: &TypeRegistry,
420    manifest: &SettingsFileManifest,
421) -> toml::map::Map<String, toml::Value> {
422    let mut table = toml::Table::new();
423
424    for tid in manifest.resource_types.iter() {
425        let ty = types.get(*tid).unwrap();
426
427        let Some(cmp) = ty.data::<ReflectComponent>() else {
428            continue;
429        };
430
431        let Some(reflect_settings_group) = ty.data::<ReflectSettingsGroup>() else {
432            continue;
433        };
434
435        let settings_group = reflect_settings_group.settings_group_name;
436        let settings_key = reflect_settings_group.settings_key_name;
437
438        let Some(component_id) = world.components().get_id(*tid) else {
439            continue;
440        };
441
442        let Some(res_entity) = world.resource_entities().get(component_id) else {
443            continue;
444        };
445        let res_entity_ref = world.entity(res_entity);
446        let Some(reflect) = cmp.reflect(res_entity_ref) else {
447            continue;
448        };
449
450        let serializer = TypedReflectSerializer::with_processor(
451            reflect.as_partial_reflect(),
452            types,
453            &SettingsSerializerProcessor,
454        );
455
456        let toml_value = if let Some(settings_key) = settings_key {
457            // convert toml value into a key value pair if settings_key is set. settings_key is only set for enums
458            toml::Value::Table(toml::Table::from_iter([(
459                settings_key.to_string(),
460                toml::Value::try_from(serializer).unwrap(),
461            )]))
462        } else {
463            // Otherwise, the whole struct is serialized into toml
464            toml::Value::try_from(serializer).unwrap()
465        };
466
467        match (
468            toml_value.as_table(),
469            table
470                .get_mut(settings_group)
471                .and_then(|value| value.as_table_mut()),
472        ) {
473            (Some(from), Some(to)) => {
474                // Merge the tables
475                for (key, value) in from.iter() {
476                    to.insert(key.clone(), value.clone());
477                }
478            }
479            _ => {
480                table.insert(settings_group.to_string(), toml_value);
481            }
482        };
483    }
484
485    table
486}
487
488/// Builds the settings file registry by scanning the type registry for settings resources.
489/// This is separated from loading to enable testing without file I/O.
490///
491/// Returns the [`SettingsFileRegistry`] that tracks which resources are associated with
492/// which settings files.
493fn build_settings_registry(
494    app_name: &str,
495    types: &TypeRegistry,
496    last_save: Tick,
497) -> SettingsFileRegistry {
498    // Build an index that remembers all of the resource types that are to be saved to
499    // each individual settings file.
500    let mut file_index = SettingsFileRegistry {
501        app_name: app_name.to_string(),
502        files: HashMap::new(),
503        save_timer: Timer::new(Duration::from_secs(1), TimerMode::Once),
504    };
505    file_index.save_timer.pause(); // Ensure timer is initially paused
506
507    let mut errors = Vec::new();
508    // Scan through types looking for resources that have the necessary traits and
509    // annotations.
510    for ty in types.iter() {
511        // All types that are relevant
512        let Some(reflect_group) = ty.data::<ReflectSettingsGroup>() else {
513            continue;
514        };
515
516        if !ty.contains::<ReflectDefault>() {
517            // Collect all the errors into a single list so that a user can see all of them at once rather than chasing them
518            // down one by one as they fix the errors.
519            errors.push(format!(
520                "Type {} has #[reflect(SettingsGroup)], which requires #[reflect(Default)] in order to save or load.",
521                ty.type_info().type_path()
522            ));
523            continue;
524        };
525
526        // If no filename is specified, use "settings"
527        let filename = reflect_group.settings_source.unwrap_or("settings");
528        let pending_file = file_index
529            .files
530            .entry(filename)
531            .or_insert(SettingsFileManifest {
532                last_save,
533                resource_types: Vec::new(),
534            });
535        pending_file.last_save = last_save;
536        pending_file.resource_types.push(ty.type_id());
537    }
538    if !errors.is_empty() {
539        panic!("{}", errors.join("\n"));
540    }
541
542    file_index
543}
544
545/// Loads a single settings file and applies its values to the world's resources.
546fn load_settings_file(
547    world: &mut World,
548    app_name: &str,
549    filename: &str,
550    manifest: &SettingsFileManifest,
551    types: &TypeRegistry,
552) {
553    // Load the TOML file
554    let store = SettingsStore::new(app_name);
555    let toml = store.load(filename);
556    if toml.is_none() {
557        warn!("Filename {filename}.toml not found");
558    }
559
560    apply_settings_to_world(world, toml.as_ref(), manifest, types);
561}
562
563/// Applies settings from a TOML table to the world's resources.
564/// This is separated from file loading to enable testing without filesystem access.
565///
566/// For each resource type in the manifest, this function either:
567/// - Updates an existing resource with values from the TOML, or
568/// - Creates a new resource with default values merged with TOML values
569fn apply_settings_to_world(
570    world: &mut World,
571    toml: Option<&toml::Table>,
572    manifest: &SettingsFileManifest,
573    types: &TypeRegistry,
574) {
575    for tid in manifest.resource_types.iter() {
576        let ty = types.get(*tid).unwrap();
577        let Some(reflect_settings_group) = ty.data::<ReflectSettingsGroup>() else {
578            continue;
579        };
580
581        let settings_group = reflect_settings_group.settings_group_name;
582        let settings_key = reflect_settings_group.settings_key_name;
583
584        let reflect_component = ty.data::<ReflectComponent>().unwrap();
585        let component_id = world.components().get_id(*tid);
586        let res_entity = component_id.and_then(|cid| world.resource_entities().get(cid));
587
588        if let Some(res_entity) = res_entity {
589            // Resource already exists, so apply toml properties to it.
590            let res_entity_mut = world.entity_mut(res_entity);
591            let Some(mut reflect) = reflect_component.reflect_mut(res_entity_mut) else {
592                continue;
593            };
594
595            if let Some(toml) = toml
596                && let Some(value) = toml.get(settings_group)
597            {
598                let value = if let Some(settings_key) = settings_key {
599                    // If there is a settings key, then we need to look one level deeper in the TOML
600                    // to find the actual properties to apply to the resource.
601                    value.get(settings_key).unwrap_or(value)
602                } else {
603                    // No settings key, so we can apply the whole section to the resource
604                    value
605                };
606
607                load_properties(value, &mut *reflect, types);
608            }
609        } else {
610            // The resource does not exist, so create a default.
611            let reflect_default = ty.data::<ReflectDefault>().unwrap();
612            let mut default_value = reflect_default.default();
613            let mut res_entity = world.spawn_empty();
614
615            if let Some(toml) = toml
616                && let Some(value) = toml.get(settings_group)
617            {
618                let value = if let Some(settings_key) = settings_key {
619                    // If there is a settings key, then we need to look one level deeper in the TOML
620                    // to find the actual properties to apply to the resource.
621                    value.get(settings_key).unwrap_or(value)
622                } else {
623                    // No settings key, so we can apply the whole section to the resource
624                    value
625                };
626
627                load_properties(value, &mut *default_value, types);
628            }
629
630            // Now add the new resource to the world.
631            reflect_component.insert(&mut res_entity, default_value.as_partial_reflect(), types);
632        }
633    }
634}
635
636fn is_option_type(type_info: &TypeInfo) -> bool {
637    type_info.type_path_table().module_path() == Some("core::option")
638        && type_info.type_path_table().ident() == Some("Option")
639}
640
641fn load_properties(value: &toml::Value, resource: &mut dyn PartialReflect, types: &TypeRegistry) {
642    let Some(tinfo) = resource.get_represented_type_info() else {
643        return;
644    };
645
646    match tinfo {
647        TypeInfo::Struct(stinfo) => {
648            if let Some(table) = value.as_table()
649                && let ReflectMut::Struct(st_reflect) = resource.reflect_mut()
650            {
651                // Deserialize matching field names, ignore ones that don't match.
652                for (idx, field) in stinfo.field_names().iter().enumerate() {
653                    if let Some(toml_field_value) = table.get(*field)
654                        && let Some(field_info) = stinfo.field_at(idx)
655                        && let Some(field_type) = types.get(field_info.type_id())
656                    {
657                        let field = st_reflect.field_at_mut(idx).unwrap();
658                        if is_option_type(field_type.type_info())
659                            && toml_field_value
660                                .as_table()
661                                .is_some_and(toml::Table::is_empty)
662                        {
663                            let mut none = DynamicEnum::new_with_index(0, "None", ());
664                            none.set_represented_type(Some(field_type.type_info()));
665                            field.apply(none.as_partial_reflect());
666                        } else {
667                            let deserializer = TypedReflectDeserializer::new(field_type, types);
668                            if let Ok(field_value) =
669                                deserializer.deserialize(toml_field_value.clone())
670                            {
671                                field.apply(&*field_value);
672                            }
673                        }
674                    }
675                }
676            }
677        }
678        TypeInfo::TupleStruct(tstinfo) => {
679            if let ReflectMut::TupleStruct(tst_reflect) = resource.reflect_mut() {
680                // tuple structs with length > 1 are always serialized as arrays
681                if tst_reflect.field_len() > 1
682                    && let Some(array) = value.as_array()
683                {
684                    for (idx, toml_field_value) in array.iter().enumerate() {
685                        if let Some(field_info) = tstinfo.field_at(idx)
686                            && let Some(field_type) = types.get(field_info.type_id())
687                        {
688                            let deserializer = TypedReflectDeserializer::new(field_type, types);
689                            if let Ok(field_value) =
690                                deserializer.deserialize(toml_field_value.clone())
691                            {
692                                // Should be safe to unwrap here since we know the field exists (above).
693                                tst_reflect.field_mut(idx).unwrap().apply(&*field_value);
694                            }
695                        }
696                    }
697                } else if tst_reflect.field_len() == 1
698                    && let Some(field_info) = tstinfo.field_at(0)
699                    && let Some(field_type) = types.get(field_info.type_id())
700                {
701                    let deserializer = TypedReflectDeserializer::new(field_type, types);
702                    if let Ok(field_value) = deserializer.deserialize(value.clone()) {
703                        // Should be safe to unwrap here since we know the field exists (above).
704                        tst_reflect.field_mut(0).unwrap().apply(&*field_value);
705                    }
706                }
707            }
708        }
709        TypeInfo::Enum(einfo) => {
710            if let ReflectMut::Enum(en_reflect) = resource.reflect_mut()
711                && let Some(variant_type) = types.get(einfo.type_id())
712            {
713                let deserializer = TypedReflectDeserializer::new(variant_type, types);
714
715                if let Ok(variant_value) = deserializer.deserialize(value.clone()) {
716                    en_reflect.apply(&*variant_value);
717                }
718            }
719        }
720        _ => {}
721    }
722}
723
724fn handle_delayed_save(
725    mut settings: ResMut<SettingsFileRegistry>,
726    time: Res<Time<Real>>,
727    mut commands: Commands,
728) {
729    settings.save_timer.tick(time.delta());
730    if settings.save_timer.just_finished() {
731        commands.queue(SaveSettings::IfChanged);
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use bevy_ecs::{change_detection::Tick, schedule::Schedule};
739    use bevy_reflect::Reflect;
740    use bevy_time::Virtual;
741    // Required to make proc macros work in bevy itself.
742    extern crate self as bevy_settings;
743
744    /// Test resource that uses default settings group name (derived from type name)
745    #[derive(Resource, SettingsGroup, Reflect, Default)]
746    #[reflect(Resource, SettingsGroup, Default)]
747    struct CounterSettings {
748        count: i32,
749    }
750
751    /// Test resource that shares the same settings group name as another resource
752    #[derive(Resource, SettingsGroup, Reflect, Default)]
753    #[reflect(Resource, SettingsGroup, Default)]
754    #[settings_group(group = "counter_settings")]
755    struct ExtraCounterSettings {
756        enabled: bool,
757    }
758
759    #[derive(Resource, SettingsGroup, Reflect, Debug, Default, PartialEq)]
760    #[reflect(Resource, SettingsGroup, Default)]
761    #[settings_group(group = "counter_settings", key = "refresh_rate")]
762    enum CounterRefreshRateSettings {
763        #[default]
764        Slow,
765        Fast,
766    }
767
768    /// Test resource that uses a different settings file
769    #[derive(Resource, SettingsGroup, Reflect, Default)]
770    #[reflect(Resource, SettingsGroup, Default)]
771    #[settings_group(file = "audio")]
772    struct AudioSettings {
773        volume: f32,
774    }
775
776    #[derive(Resource, SettingsGroup, Reflect, Default)]
777    #[reflect(Resource, SettingsGroup, Default)]
778    struct OptionalSettings {
779        value: Option<u32>,
780    }
781
782    #[test]
783    fn test_build_registry_single_struct_resource() {
784        let mut types = TypeRegistry::default();
785        types.register::<CounterSettings>();
786
787        let registry = build_settings_registry("test_app", &types, Tick::new(0));
788
789        assert_eq!(registry.app_name, "test_app");
790        assert_eq!(registry.files.len(), 1);
791        assert!(registry.files.contains_key("settings"));
792
793        let manifest = registry.files.get("settings").unwrap();
794        assert_eq!(manifest.resource_types.len(), 1);
795    }
796
797    #[test]
798    fn test_build_registry_single_enum_resource() {
799        let mut types = TypeRegistry::default();
800        types.register::<CounterRefreshRateSettings>();
801
802        let registry = build_settings_registry("test_app", &types, Tick::new(0));
803
804        assert_eq!(registry.app_name, "test_app");
805        assert_eq!(registry.files.len(), 1);
806        assert!(registry.files.contains_key("settings"));
807
808        let manifest = registry.files.get("settings").unwrap();
809        assert_eq!(manifest.resource_types.len(), 1);
810    }
811
812    #[test]
813    fn test_build_registry_merged_groups() {
814        let mut types = TypeRegistry::default();
815        types.register::<CounterSettings>();
816        types.register::<ExtraCounterSettings>();
817
818        let registry = build_settings_registry("test_app", &types, Tick::new(0));
819
820        // Both resources should be in the same file
821        assert_eq!(registry.files.len(), 1);
822        assert!(registry.files.contains_key("settings"));
823
824        let manifest = registry.files.get("settings").unwrap();
825        // Both resources should be tracked
826        assert_eq!(manifest.resource_types.len(), 2);
827    }
828
829    #[test]
830    fn test_build_registry_separate_files() {
831        let mut types = TypeRegistry::default();
832        types.register::<CounterSettings>();
833        types.register::<AudioSettings>();
834
835        let registry = build_settings_registry("test_app", &types, Tick::new(0));
836
837        // Resources should be in different files
838        assert_eq!(registry.files.len(), 2);
839        assert!(registry.files.contains_key("settings"));
840        assert!(registry.files.contains_key("audio"));
841
842        let settings_manifest = registry.files.get("settings").unwrap();
843        assert_eq!(settings_manifest.resource_types.len(), 1);
844
845        let audio_manifest = registry.files.get("audio").unwrap();
846        assert_eq!(audio_manifest.resource_types.len(), 1);
847    }
848
849    #[test]
850    fn test_resources_to_toml_merges_same_group() {
851        let mut world = World::new();
852        let mut types = TypeRegistry::default();
853        types.register::<CounterSettings>();
854        types.register::<ExtraCounterSettings>();
855        types.register::<CounterRefreshRateSettings>();
856
857        // Insert both resources
858        world.insert_resource(CounterSettings { count: 42 });
859        world.insert_resource(ExtraCounterSettings { enabled: true });
860        world.insert_resource(CounterRefreshRateSettings::Fast);
861
862        // Build a manifest with both resource types
863        let manifest = SettingsFileManifest {
864            last_save: Tick::new(0),
865            resource_types: vec![
866                TypeId::of::<CounterSettings>(),
867                TypeId::of::<ExtraCounterSettings>(),
868                TypeId::of::<CounterRefreshRateSettings>(),
869            ],
870        };
871
872        let table = resources_to_toml(&world, &types, &manifest);
873
874        // Both resources should be merged into the same "counter_settings" section
875        assert!(table.contains_key("counter_settings"));
876        let counter_section = table.get("counter_settings").unwrap().as_table().unwrap();
877
878        // Check that fields are present in the merged section
879        assert_eq!(
880            counter_section.get("count").unwrap().as_integer().unwrap(),
881            42
882        );
883        assert!(counter_section.get("enabled").unwrap().as_bool().unwrap());
884        assert_eq!(
885            counter_section
886                .get("refresh_rate")
887                .unwrap()
888                .as_str()
889                .unwrap(),
890            "Fast"
891        );
892    }
893
894    #[test]
895    fn test_round_trip_serialization() {
896        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug, Default)]
897        #[reflect(Resource, SettingsGroup, Default)]
898        struct SingleFieldTupleStruct(u8);
899
900        #[derive(Reflect, PartialEq, Debug, Default)]
901        #[reflect(Default)]
902        struct NestedStruct {
903            a: u8,
904            b: u16,
905        }
906
907        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug, Default)]
908        #[reflect(Resource, SettingsGroup, Default)]
909        struct MultiFieldTupleStruct(u8, NestedStruct);
910
911        #[derive(Resource, SettingsGroup, Reflect, Default)]
912        #[reflect(Resource, SettingsGroup, Default)]
913        struct NewTypeSingleTupleStruct(SingleFieldTupleStruct);
914
915        #[derive(Resource, SettingsGroup, Reflect, Default)]
916        #[reflect(Resource, SettingsGroup, Default)]
917        struct NewTypeMultiTupleStruct(SingleFieldTupleStruct, MultiFieldTupleStruct);
918
919        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug, Default)]
920        #[reflect(Resource, SettingsGroup, Default)]
921        enum EnumUnitVariant {
922            #[default]
923            A,
924        }
925
926        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug)]
927        #[reflect(Resource, SettingsGroup, Default)]
928        enum EnumSingleTupleVariant {
929            A(u8),
930        }
931
932        impl Default for EnumSingleTupleVariant {
933            fn default() -> Self {
934                EnumSingleTupleVariant::A(0)
935            }
936        }
937
938        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug)]
939        #[reflect(Resource, SettingsGroup, Default)]
940        enum EnumMultiTupleVariant {
941            A(u16, u32),
942        }
943
944        impl Default for EnumMultiTupleVariant {
945            fn default() -> Self {
946                EnumMultiTupleVariant::A(0, 0)
947            }
948        }
949
950        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug)]
951        #[reflect(Resource, SettingsGroup, Default)]
952        enum EnumStructVariant {
953            A { x: u8, y: u16 },
954        }
955
956        impl Default for EnumStructVariant {
957            fn default() -> Self {
958                EnumStructVariant::A { x: 0, y: 0 }
959            }
960        }
961
962        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug)]
963        #[reflect(Resource, SettingsGroup, Default)]
964        enum EnumSingleNewTypeVariant {
965            A(SingleFieldTupleStruct),
966        }
967
968        impl Default for EnumSingleNewTypeVariant {
969            fn default() -> Self {
970                EnumSingleNewTypeVariant::A(SingleFieldTupleStruct(0))
971            }
972        }
973
974        #[derive(Resource, SettingsGroup, Reflect, PartialEq, Debug)]
975        #[reflect(Resource, SettingsGroup, Default)]
976        enum EnumMultiNewTypeVariant {
977            A(SingleFieldTupleStruct, MultiFieldTupleStruct),
978        }
979
980        impl Default for EnumMultiNewTypeVariant {
981            fn default() -> Self {
982                EnumMultiNewTypeVariant::A(
983                    SingleFieldTupleStruct(0),
984                    MultiFieldTupleStruct(0, NestedStruct { a: 0, b: 0 }),
985                )
986            }
987        }
988
989        let mut world = World::new();
990        let mut types = TypeRegistry::default();
991
992        types.register::<CounterSettings>();
993        types.register::<ExtraCounterSettings>();
994        types.register::<CounterRefreshRateSettings>();
995        types.register::<SingleFieldTupleStruct>();
996        types.register::<MultiFieldTupleStruct>();
997        types.register::<NewTypeSingleTupleStruct>();
998        types.register::<NewTypeMultiTupleStruct>();
999        types.register::<EnumUnitVariant>();
1000        types.register::<EnumSingleTupleVariant>();
1001        types.register::<EnumMultiTupleVariant>();
1002        types.register::<EnumStructVariant>();
1003        types.register::<EnumSingleNewTypeVariant>();
1004        types.register::<EnumMultiNewTypeVariant>();
1005
1006        // Insert resources with specific values
1007        world.insert_resource(CounterSettings { count: 123 });
1008        world.insert_resource(ExtraCounterSettings { enabled: false });
1009        world.insert_resource(CounterRefreshRateSettings::Fast);
1010        world.insert_resource(SingleFieldTupleStruct(1));
1011        world.insert_resource(MultiFieldTupleStruct(2, NestedStruct { a: 1, b: 2 }));
1012        world.insert_resource(NewTypeSingleTupleStruct(SingleFieldTupleStruct(1)));
1013        world.insert_resource(NewTypeMultiTupleStruct(
1014            SingleFieldTupleStruct(1),
1015            MultiFieldTupleStruct(2, NestedStruct { a: 1, b: 2 }),
1016        ));
1017        world.insert_resource(EnumUnitVariant::A);
1018        world.insert_resource(EnumSingleTupleVariant::A(1));
1019        world.insert_resource(EnumMultiTupleVariant::A(1, 2));
1020        world.insert_resource(EnumStructVariant::A { x: 1, y: 2 });
1021        world.insert_resource(EnumSingleNewTypeVariant::A(SingleFieldTupleStruct(1)));
1022        world.insert_resource(EnumMultiNewTypeVariant::A(
1023            SingleFieldTupleStruct(1),
1024            MultiFieldTupleStruct(2, NestedStruct { a: 1, b: 2 }),
1025        ));
1026
1027        // Build a manifest with both resource types
1028        let manifest = SettingsFileManifest {
1029            last_save: Tick::new(0),
1030            resource_types: vec![
1031                TypeId::of::<CounterSettings>(),
1032                TypeId::of::<ExtraCounterSettings>(),
1033                TypeId::of::<CounterRefreshRateSettings>(),
1034                TypeId::of::<SingleFieldTupleStruct>(),
1035                TypeId::of::<MultiFieldTupleStruct>(),
1036                TypeId::of::<NewTypeSingleTupleStruct>(),
1037                TypeId::of::<NewTypeMultiTupleStruct>(),
1038                TypeId::of::<EnumUnitVariant>(),
1039                TypeId::of::<EnumSingleTupleVariant>(),
1040                TypeId::of::<EnumMultiTupleVariant>(),
1041                TypeId::of::<EnumStructVariant>(),
1042                TypeId::of::<EnumSingleNewTypeVariant>(),
1043                TypeId::of::<EnumMultiNewTypeVariant>(),
1044            ],
1045        };
1046
1047        // Serialize to TOML
1048        let table = resources_to_toml(&world, &types, &manifest);
1049
1050        // Create a new world and apply the TOML
1051        let mut new_world = World::new();
1052        apply_settings_to_world(&mut new_world, Some(&table), &manifest, &types);
1053
1054        // Verify resources were created with correct values
1055        let counter = new_world.get_resource::<CounterSettings>().unwrap();
1056        assert_eq!(counter.count, 123);
1057
1058        let extra = new_world.get_resource::<ExtraCounterSettings>().unwrap();
1059        assert!(!extra.enabled);
1060
1061        let refresh_rate = new_world
1062            .get_resource::<CounterRefreshRateSettings>()
1063            .unwrap();
1064        assert_eq!(*refresh_rate, CounterRefreshRateSettings::Fast);
1065
1066        let single_field_tuple_struct = new_world.get_resource::<SingleFieldTupleStruct>().unwrap();
1067        assert_eq!(single_field_tuple_struct.0, 1);
1068
1069        let multi_field_tuple_struct = new_world.get_resource::<MultiFieldTupleStruct>().unwrap();
1070        assert_eq!(multi_field_tuple_struct.0, 2);
1071        assert_eq!(multi_field_tuple_struct.1.a, 1);
1072        assert_eq!(multi_field_tuple_struct.1.b, 2);
1073
1074        let new_type_single_tuple_struct = new_world
1075            .get_resource::<NewTypeSingleTupleStruct>()
1076            .unwrap();
1077        assert_eq!(new_type_single_tuple_struct.0 .0, 1);
1078
1079        let new_type_multi_tuple_struct =
1080            new_world.get_resource::<NewTypeMultiTupleStruct>().unwrap();
1081        assert_eq!(new_type_multi_tuple_struct.0 .0, 1);
1082        assert_eq!(new_type_multi_tuple_struct.1 .0, 2);
1083        assert_eq!(new_type_multi_tuple_struct.1 .1.a, 1);
1084        assert_eq!(new_type_multi_tuple_struct.1 .1.b, 2);
1085
1086        let enum_unit_variant = new_world.get_resource::<EnumUnitVariant>().unwrap();
1087        assert_eq!(*enum_unit_variant, EnumUnitVariant::A);
1088
1089        let enum_single_tuple_variant = new_world.get_resource::<EnumSingleTupleVariant>().unwrap();
1090        assert_eq!(*enum_single_tuple_variant, EnumSingleTupleVariant::A(1));
1091
1092        let enum_multi_tuple_variant = new_world.get_resource::<EnumMultiTupleVariant>().unwrap();
1093        assert_eq!(*enum_multi_tuple_variant, EnumMultiTupleVariant::A(1, 2));
1094
1095        let enum_struct_variant = new_world.get_resource::<EnumStructVariant>().unwrap();
1096        assert_eq!(*enum_struct_variant, EnumStructVariant::A { x: 1, y: 2 });
1097
1098        let enum_single_new_type_variant = new_world
1099            .get_resource::<EnumSingleNewTypeVariant>()
1100            .unwrap();
1101        assert_eq!(
1102            *enum_single_new_type_variant,
1103            EnumSingleNewTypeVariant::A(SingleFieldTupleStruct(1))
1104        );
1105
1106        let enum_multi_new_type_variant =
1107            new_world.get_resource::<EnumMultiNewTypeVariant>().unwrap();
1108        assert_eq!(
1109            *enum_multi_new_type_variant,
1110            EnumMultiNewTypeVariant::A(
1111                SingleFieldTupleStruct(1),
1112                MultiFieldTupleStruct(2, NestedStruct { a: 1, b: 2 })
1113            )
1114        );
1115    }
1116
1117    #[test]
1118    fn test_round_trip_with_existing_resources() {
1119        let mut world = World::new();
1120        let mut types = TypeRegistry::default();
1121        types.register::<CounterSettings>();
1122        types.register::<CounterRefreshRateSettings>();
1123
1124        // Insert resource with initial values
1125        world.insert_resource(CounterSettings { count: 100 });
1126        world.insert_resource(CounterRefreshRateSettings::Fast);
1127
1128        let manifest = SettingsFileManifest {
1129            last_save: Tick::new(0),
1130            resource_types: vec![
1131                TypeId::of::<CounterSettings>(),
1132                TypeId::of::<CounterRefreshRateSettings>(),
1133            ],
1134        };
1135
1136        // Serialize
1137        let table = resources_to_toml(&world, &types, &manifest);
1138
1139        // Modify the resource
1140        world.resource_mut::<CounterSettings>().count = 999;
1141        *world.resource_mut::<CounterRefreshRateSettings>() = CounterRefreshRateSettings::Slow;
1142
1143        // Apply TOML (should restore the original value)
1144        apply_settings_to_world(&mut world, Some(&table), &manifest, &types);
1145
1146        let counter = world.get_resource::<CounterSettings>().unwrap();
1147        assert_eq!(counter.count, 100);
1148        let refresh_rate = world.get_resource::<CounterRefreshRateSettings>().unwrap();
1149        assert_eq!(*refresh_rate, CounterRefreshRateSettings::Fast);
1150    }
1151
1152    #[test]
1153    fn test_partial_toml_preserves_missing_fields() {
1154        let mut world = World::new();
1155        let mut types = TypeRegistry::default();
1156        types.register::<CounterSettings>();
1157        types.register::<ExtraCounterSettings>();
1158        types.register::<CounterRefreshRateSettings>();
1159
1160        // Insert resources with specific values
1161        world.insert_resource(CounterSettings { count: 50 });
1162        world.insert_resource(ExtraCounterSettings { enabled: true });
1163        world.insert_resource(CounterRefreshRateSettings::Fast);
1164
1165        // Create a TOML table that only contains one field from one resource
1166        let mut table = toml::Table::new();
1167        let mut counter_section = toml::Table::new();
1168        counter_section.insert("count".to_string(), toml::Value::Integer(999));
1169        table.insert(
1170            "counter_settings".to_string(),
1171            toml::Value::Table(counter_section),
1172        );
1173        // Note: "enabled" field is missing from the TOML
1174
1175        let manifest = SettingsFileManifest {
1176            last_save: Tick::new(0),
1177            resource_types: vec![
1178                TypeId::of::<CounterSettings>(),
1179                TypeId::of::<ExtraCounterSettings>(),
1180                TypeId::of::<CounterRefreshRateSettings>(),
1181            ],
1182        };
1183
1184        // Apply the partial TOML
1185        apply_settings_to_world(&mut world, Some(&table), &manifest, &types);
1186
1187        // Verify count was updated
1188        let counter = world.get_resource::<CounterSettings>().unwrap();
1189        assert_eq!(counter.count, 999);
1190
1191        // Verify enabled was preserved (not overwritten with default false)
1192        let extra = world.get_resource::<ExtraCounterSettings>().unwrap();
1193        assert!(extra.enabled);
1194
1195        // Verify refresh_rate was preserved
1196        let refresh_rate = world.get_resource::<CounterRefreshRateSettings>().unwrap();
1197        assert_eq!(*refresh_rate, CounterRefreshRateSettings::Fast);
1198    }
1199
1200    #[test]
1201    fn test_handle_delayed_save_ticks_with_real_time_while_paused() {
1202        let mut world = World::new();
1203        world.insert_resource(Time::<Real>::default());
1204        world.insert_resource(Time::<Virtual>::default());
1205        // Virtual time is paused, so the delayed save timer must use real time.
1206        world.resource_mut::<Time<Virtual>>().pause();
1207
1208        // SettingsFileRegistry with a delayed save timer
1209        world.insert_resource(SettingsFileRegistry {
1210            app_name: "test_app".to_string(),
1211            files: HashMap::new(),
1212            save_timer: {
1213                let mut timer = Timer::new(Duration::from_millis(500), TimerMode::Once);
1214                timer.unpause();
1215                timer
1216            },
1217        });
1218
1219        // Simulate real time advancing
1220        world
1221            .resource_mut::<Time<Real>>()
1222            .advance_by(Duration::from_millis(600));
1223
1224        let mut schedule = Schedule::default();
1225        schedule.add_systems(handle_delayed_save);
1226        schedule.run(&mut world);
1227
1228        let registry = world.resource::<SettingsFileRegistry>();
1229        assert!(registry.save_timer.just_finished());
1230    }
1231
1232    #[test]
1233    fn test_none_value_apply_settings() {
1234        let mut world = World::new();
1235        let mut other_world = World::new();
1236        let mut types = TypeRegistry::default();
1237
1238        types.register::<OptionalSettings>();
1239
1240        let manifest = SettingsFileManifest {
1241            last_save: Tick::new(0),
1242            resource_types: vec![TypeId::of::<OptionalSettings>()],
1243        };
1244
1245        world.insert_resource(OptionalSettings { value: None });
1246        let table = resources_to_toml(&world, &types, &manifest);
1247
1248        other_world.insert_resource(OptionalSettings { value: Some(12) });
1249        apply_settings_to_world(&mut other_world, Some(&table), &manifest, &types);
1250
1251        let settings = other_world.get_resource::<OptionalSettings>().unwrap();
1252        assert_eq!(settings.value, None);
1253    }
1254}