Skip to main content

bevy_settings_lib/
lib.rs

1//! A flexible settings management library for Bevy with async saving, multiple formats, and built‑in validation.
2//!
3//! This library provides a convenient way to save, load, and reload settings in Bevy applications.
4//! It supports text formats (TOML, JSON) and binary (postcard) with atomic write‑then‑rename
5//! to prevent file corruption.
6//!
7//! # Features
8//!
9//! - **Any number of configurations** – each configuration has its own data type and file name.
10//! - **File names can be explicit or derived from the struct name** (automatically converted to snake_case).
11//! - **Asynchronous saving** with atomic write‑then‑rename – files are never left in a corrupted state.
12//! - **Persistent worker thread** – each settings type has a dedicated background thread that processes save requests sequentially, eliminating file race conditions.
13//! - **Format support**: TOML (default), JSON, binary (postcard).
14//! - **Load from OS‑standard directories** (via `directories` crate) **or from the game's local folder**.
15//! - **Events**: `PersistSetting<S>`, `PersistAllSettings`, `ReloadSetting<S>`, `SettingsSaveError<S>`.
16//! - **Partial loading** – if a file does not exist, `S::default()` is used.
17//! - **Validation** – every settings type must implement `ValidatedSetting` to normalize values after loading and before saving.
18//!
19//! # Quick Example
20//!
21//! ```no_run
22//! use bevy::prelude::*;
23//! use bevy_settings_lib::{SettingsPlugin, PersistSetting, SettingsPluginConfig, FormatKind, ValidatedSetting, SettingsStorage};
24//! use serde::{Serialize, Deserialize};
25//!
26//! #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
27//! struct MySettings {
28//!     volume: f32,
29//!     fullscreen: bool,
30//! }
31//!
32//! // Mandatory validation implementation (can be empty if no validation needed)
33//! impl ValidatedSetting for MySettings {
34//!     fn validate(&mut self) {
35//!         self.volume = self.volume.clamp(0.0, 1.0);
36//!     }
37//! }
38//!
39//! fn main() {
40//!     // Use the system configuration directory (AppData, ~/.config, etc.)
41//!     let config = SettingsPluginConfig {
42//!         format: FormatKind::Toml,
43//!         company: "MyCompany".into(),
44//!         project: "MyGame".into(),
45//!         file_name: None, // auto‑name → "my_settings"
46//!         storage: SettingsStorage::SystemConfigDir,
47//!         ..Default::default()
48//!     };
49//!     App::new()
50//!         .add_plugins(SettingsPlugin::<MySettings>::from_config(config))
51//!         .add_systems(Update, save_after_delay)
52//!         .run();
53//! }
54//!
55//! fn save_after_delay(
56//!     mut commands: Commands,
57//!     time: Res<Time>,
58//!     mut timer: Local<Option<Timer>>,
59//! ) {
60//!     // Create a one‑shot timer on first run
61//!     if timer.is_none() {
62//!         *timer = Some(Timer::from_seconds(2.0, TimerMode::Once));
63//!     }
64//!     let timer = timer.as_mut().unwrap();
65//!     timer.tick(time.delta());
66//!     if timer.just_finished() {
67//!         commands.trigger(PersistSetting::<MySettings> { value: None });
68//!     }
69//! }
70//! ```
71//!
72//! # Important Notes
73//!
74//! - **Asynchronous saving**: When the application exits, the last changes may be lost if the save thread hasn't finished.
75//!   For guaranteed persistence, implement synchronous saving (e.g., in an `OnExit` system).
76//! - **Company and project names** must not contain invalid characters for `ProjectDirs` and **cannot be empty when using `SystemConfigDir`** – the library will panic.
77//!   With `GameLocalDir` these fields are optional (may be empty).
78//! - **No auto‑save and no auto‑create** – the developer decides when to trigger saving. The settings file is only created on the first explicit save.
79//! - **First launch defaults**: The library does not create a file automatically. Use a system to adjust `S::default()` to runtime conditions (screen resolution, language, etc.) by modifying the resource directly. Save explicitly only when needed.
80//!
81//! # Plugin Configuration
82//!
83//! Use `SettingsPluginConfig` to choose the format, domain, company, project (for directory),
84//! file name, and storage type. Defaults are TOML format, domain `"com"`, and `SystemConfigDir` storage.
85//!
86//! ```no_run
87//! # use bevy::prelude::*;
88//! # use bevy_settings_lib::{SettingsPlugin, SettingsPluginConfig, FormatKind, SettingsStorage, ValidatedSetting};
89//! # use serde::{Serialize, Deserialize};
90//! # #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
91//! # struct MySettings;
92//! # impl ValidatedSetting for MySettings { fn validate(&mut self) {} }
93//! # let mut app = App::new();
94//! let config = SettingsPluginConfig {
95//!     format: FormatKind::Json,
96//!     company: "MyCompany".into(),
97//!     project: "MyGame".into(),
98//!     file_name: Some("custom_name".into()),
99//!     storage: SettingsStorage::GameLocalDir, // save next to the .exe
100//!     ..Default::default()
101//! };
102//! app.add_plugins(SettingsPlugin::<MySettings>::from_config(config));
103//! ```
104
105use 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// ================================
119// 1. Error Handling
120// ================================
121
122#[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
134// ================================
135// 2. Text Formats (TOML, JSON)
136// ================================
137
138pub 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
170// ================================
171// 3. Binary Format (postcard)
172// ================================
173
174fn 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
185// ================================
186// 4. Setting Types and Validation
187// ================================
188
189pub 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
198/// Trait for validating and normalizing setting values.
199/// **Mandatory to implement** for all types used with `SettingsPlugin`.
200/// Called automatically:
201/// - after loading from a file (or `S::default()` if the file does not exist),
202/// - after `ReloadSetting`,
203/// - **before saving** (`PersistSetting` and `PersistAllSettings`),
204/// - **when a new value is provided** in `PersistSetting { value: Some(...) }`.
205///
206/// If validation is not needed, implement the method as empty.
207pub trait ValidatedSetting {
208    fn validate(&mut self);
209}
210
211// ================================
212// 5. Plugin Configuration
213// ================================
214
215/// Storage type for settings files.
216#[derive(Clone, Copy, PartialEq, Eq)]
217pub enum SettingsStorage {
218    /// System configuration directory:
219    /// - Windows: `%APPDATA%\Company\Project\config`
220    /// - macOS:   `~/Library/Application Support/company/project/config`
221    /// - Linux:   `~/.config/company/project/config`
222    SystemConfigDir,
223    /// Local directory where the game executable resides (next to the .exe).
224    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    /// Validates the configuration. Panics if:
246    /// - for `SystemConfigDir` `company` or `project` are empty, or `ProjectDirs` cannot be created
247    /// - for `GameLocalDir` no validation is performed (company/project may be empty)
248    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                // For local mode, company and project are not used; no validation needed.
273                // However, we can warn if they are empty (optional).
274                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// ================================
299// 6. Events (Bevy 0.18 Observers)
300// ================================
301
302#[derive(Event)]
303pub struct PersistAllSettings;
304
305#[derive(Event)]
306pub struct PersistSetting<S: Setting> {
307    pub value: Option<S>,
308}
309
310/// Event that triggers a reload of the settings from disk.
311///
312/// If the settings file does not exist or cannot be read, the current in‑memory resource is **not** changed.
313/// To reset to default values, use `PersistSetting` with `Some(S::default())` or implement your own logic.
314#[derive(Event)]
315pub struct ReloadSetting<S: Setting> {
316    pub _phantom: PhantomData<S>,
317}
318
319/// Event emitted when a settings save operation fails.
320#[derive(Event)]
321pub struct SettingsSaveError<S: Setting> {
322    pub error: SettingsError,
323    pub _phantom: PhantomData<S>,
324}
325
326// ================================
327// 7. Internal Resources
328// ================================
329
330#[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/// Resource for receiving errors from background threads.
341#[derive(Resource)]
342struct SettingsErrorReceiver<S: Setting> {
343    receiver: mpsc::Receiver<SettingsError>,
344    _marker: PhantomData<S>,
345}
346
347/// Resource that owns the background save worker thread and its channel.
348#[derive(Resource)]
349struct SaveWorker<S: Setting> {
350    /// Sender to queue settings to be saved. `None` is sent to signal shutdown.
351    sender: Sender<Option<S>>,
352    /// Handle to the background thread; joined on drop.
353    handle: Option<JoinHandle<()>>,
354    _marker: PhantomData<S>,
355}
356
357impl<S: Setting> Drop for SaveWorker<S> {
358    fn drop(&mut self) {
359        // Send termination signal if channel is still open.
360        let _ = self.sender.send(None);
361        if let Some(handle) = self.handle.take() {
362            // Wait for the thread to finish pending saves.
363            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
391// ================================
392// 8. Main Plugin
393// ================================
394
395pub struct SettingsPlugin<S> {
396    config: SettingsPluginConfig,
397    _marker: PhantomData<S>,
398}
399
400impl<S: Setting + ValidatedSetting> SettingsPlugin<S> {
401    /// Creates a plugin from a ready‑made configuration.
402    /// Performs configuration validation (panics if `company` or `project` are invalid
403    /// when using `SystemConfigDir`).
404    pub fn from_config(config: SettingsPluginConfig) -> Self {
405        config.validate();
406        Self {
407            config,
408            _marker: PhantomData,
409        }
410    }
411
412    /// Returns the file name (without extension) based on the configuration.
413    fn file_stem(&self) -> String {
414        if let Some(ref name) = self.config.file_name {
415            name.clone()
416        } else {
417            // Convert the type name to "snake_case" and strip modules.
418            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    /// Returns the base directory depending on the chosen storage.
434    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        // Apply validation (if the type implements a custom method, it will be called).
470        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    // --- Helper function for saving ---
505    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                    // Remove the temporary file if rename failed.
533                    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                // Try to delete the corrupted temporary file.
542                let _ = std::fs::remove_file(temp_path);
543            }
544        }
545    }
546
547    // --- Observers ---
548
549    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        // Clone settings and send to worker channel.
564        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(); // validation after loading.
621                *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    /// System that processes errors from background threads and sends them as events.
631    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        // Validation (in case default already needs correction).
659        initial_value.validate();
660
661        let dir = self.directory();
662        let path = self.path();
663
664        // Create channels: one for errors, one for save queue.
665        let (error_sender, error_receiver) = mpsc::channel();
666        let (save_sender, save_receiver): (Sender<Option<S>>, Receiver<Option<S>>) =
667            mpsc::channel();
668
669        // Attempt to create directory, send error through channel if fails
670        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        // Clone the data needed for the worker thread BEFORE moving it into the closure
679        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                // Worker loop: receive Option<S> from channel.
687                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                            // Termination signal received.
701                            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// ================================
730// 9. Test Utilities (tests only)
731// ================================
732
733#[cfg(test)]
734mod test_utils {
735    use super::*;
736    use std::path::PathBuf;
737
738    /// Returns `true` if test files should be cleaned up after tests (default).
739    /// Set the environment variable `KEEP_TEST_FILES=1` to disable cleanup.
740    pub fn should_cleanup() -> bool {
741        std::env::var("KEEP_TEST_FILES").is_err()
742    }
743
744    /// Deletes the given files or directories if `should_cleanup()` is true.
745    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    /// Returns the path to the configuration directory for the given `SettingsPluginConfig`.
762    /// For `GameLocalDir` in tests we use a temporary directory, because the real executable
763    /// path is unstable in the test environment.
764    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                // For tests we use a temporary directory to avoid dependence on .exe location.
774                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// ================================
786// 10. Tests
787// ================================
788
789#[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    // Mandatory implementation (empty if not needed).
871    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        // Check name generation.
898        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        // Modify values.
904        {
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        // Save.
916        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        // Content verification.
933        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 (delete only files, leave directory untouched).
944        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        // Modify values.
1053        {
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        // Save.
1066        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        // Content verification.
1084        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        // Simulate external change with invalid values.
1096        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        // Reload.
1107        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); // clamped
1124        assert_eq!(config.vsync, false);
1125
1126        let prefs = app.world().resource::<UserPrefs2>();
1127        assert_eq!(prefs.music_volume, 1.0); // clamped
1128        assert_eq!(prefs.sfx_volume, 1.0); // clamped
1129        assert_eq!(prefs.controls_inverted, false);
1130
1131        // Cleanup.
1132        cleanup_paths(&[game_path, prefs_path]);
1133    }
1134}