Skip to main content

bevy_settings_lib/
lib.rs

1//! A flexible settings management library for Bevy with async saving, multiple formats, and Configuration/Preferences separation.
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//! - **Format support**: TOML (default), JSON, binary (postcard).
13//! - **Load from OS‑standard directories** (via `directories` crate) **or from the game's local folder**.
14//! - **Events**: `PersistSetting<S>`, `PersistAllSettings`, `ReloadSetting<S>`, `SettingsSaveError<S>`.
15//! - **Partial loading** – if a file does not exist, `S::default()` is used.
16//! - **Validation** – every settings type must implement `ValidatedSetting` to normalize values after loading and before saving.
17//!
18//! # Quick Example
19//!
20//! ```no_run
21//! use bevy::prelude::*;
22//! use bevy_settings_lib::{SettingsPlugin, PersistSetting, SettingsPluginConfig, FormatKind, ValidatedSetting, SettingsStorage};
23//! use serde::{Serialize, Deserialize};
24//!
25//! #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
26//! struct MySettings {
27//!     volume: f32,
28//!     fullscreen: bool,
29//! }
30//!
31//! // Mandatory validation implementation (can be empty if no validation needed)
32//! impl ValidatedSetting for MySettings {
33//!     fn validate(&mut self) {
34//!         self.volume = self.volume.clamp(0.0, 1.0);
35//!     }
36//! }
37//!
38//! fn main() {
39//!     // Use the system configuration directory (AppData, ~/.config, etc.)
40//!     let config = SettingsPluginConfig {
41//!         format: FormatKind::Toml,
42//!         company: "MyCompany".into(),
43//!         project: "MyGame".into(),
44//!         file_name: None, // auto‑name → "my_settings"
45//!         storage: SettingsStorage::SystemConfigDir,
46//!         ..Default::default()
47//!     };
48//!     App::new()
49//!         .add_plugins(SettingsPlugin::<MySettings>::from_config(config))
50//!         .add_systems(Update, save_after_delay)
51//!         .run();
52//! }
53//!
54//! fn save_after_delay(
55//!     mut commands: Commands,
56//!     time: Res<Time>,
57//!     mut timer: Local<Option<Timer>>,
58//! ) {
59//!     // Create a one‑shot timer on first run
60//!     if timer.is_none() {
61//!         *timer = Some(Timer::from_seconds(2.0, TimerMode::Once));
62//!     }
63//!     let timer = timer.as_mut().unwrap();
64//!     timer.tick(time.delta());
65//!     if timer.just_finished() {
66//!         commands.trigger(PersistSetting::<MySettings> { value: None });
67//!     }
68//! }
69//! ```
70//!
71//! # Important Notes
72//!
73//! - **Asynchronous saving**: When the application exits, the last changes may be lost if the save thread hasn't finished.
74//!   For guaranteed persistence, implement synchronous saving (e.g., in an `OnExit` system).
75//! - **Company and project names** must not contain invalid characters for `ProjectDirs` and **cannot be empty when using `SystemConfigDir`** – the library will panic.
76//!   With `GameLocalDir` these fields are optional (may be empty).
77//! - **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.
78//! - **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.
79//!
80//! # Plugin Configuration
81//!
82//! Use `SettingsPluginConfig` to choose the format, domain, company, project (for directory),
83//! file name, and storage type. Defaults are TOML format, domain `"com"`, and `SystemConfigDir` storage.
84//!
85//! ```no_run
86//! # use bevy::prelude::*;
87//! # use bevy_settings_lib::{SettingsPlugin, SettingsPluginConfig, FormatKind, SettingsStorage, ValidatedSetting};
88//! # use serde::{Serialize, Deserialize};
89//! # #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
90//! # struct MySettings;
91//! # impl ValidatedSetting for MySettings { fn validate(&mut self) {} }
92//! # let mut app = App::new();
93//! let config = SettingsPluginConfig {
94//!     format: FormatKind::Json,
95//!     company: "MyCompany".into(),
96//!     project: "MyGame".into(),
97//!     file_name: Some("custom_name".into()),
98//!     storage: SettingsStorage::GameLocalDir, // save next to the .exe
99//!     ..Default::default()
100//! };
101//! app.add_plugins(SettingsPlugin::<MySettings>::from_config(config));
102//! ```
103
104use std::{
105    fmt::Debug,
106    marker::PhantomData,
107    path::{Path, PathBuf},
108    sync::{mpsc, Mutex},
109    thread,
110};
111
112use bevy::prelude::*;
113use directories::ProjectDirs;
114use serde::{Deserialize, Serialize};
115use thiserror::Error;
116
117// ================================
118// 1. Error Handling
119// ================================
120
121#[derive(Error, Debug)]
122pub enum SettingsError {
123    #[error("IO error: {0}")]
124    Io(#[from] std::io::Error),
125    #[error("Serialization error: {0}")]
126    Serialize(String),
127    #[error("Deserialization error: {0}")]
128    Deserialize(String),
129}
130
131pub type SettingsResult<T> = Result<T, SettingsError>;
132
133// ================================
134// 2. Text Formats (TOML, JSON)
135// ================================
136
137pub trait SettingsFormat: Send + Sync + 'static {
138    fn file_extension() -> &'static str;
139    fn serialize<T: Serialize>(value: &T) -> SettingsResult<String>;
140    fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T>;
141}
142
143pub struct TomlFormat;
144impl SettingsFormat for TomlFormat {
145    fn file_extension() -> &'static str {
146        "toml"
147    }
148    fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
149        toml::to_string(value).map_err(|e| SettingsError::Serialize(e.to_string()))
150    }
151    fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
152        toml::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
153    }
154}
155
156pub struct JsonFormat;
157impl SettingsFormat for JsonFormat {
158    fn file_extension() -> &'static str {
159        "json"
160    }
161    fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
162        serde_json::to_string_pretty(value).map_err(|e| SettingsError::Serialize(e.to_string()))
163    }
164    fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
165        serde_json::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
166    }
167}
168
169// ================================
170// 3. Binary Format (postcard)
171// ================================
172
173fn write_binary<T: Serialize>(path: &Path, value: &T) -> SettingsResult<()> {
174    let bytes =
175        postcard::to_allocvec(value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
176    std::fs::write(path, bytes).map_err(SettingsError::Io)
177}
178
179fn read_binary<T: for<'de> Deserialize<'de>>(path: &Path) -> SettingsResult<T> {
180    let bytes = std::fs::read(path).map_err(SettingsError::Io)?;
181    postcard::from_bytes(&bytes).map_err(|e| SettingsError::Deserialize(e.to_string()))
182}
183
184// ================================
185// 4. Setting Types and Validation
186// ================================
187
188pub trait Setting:
189    Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
190{
191}
192impl<T> Setting for T where
193    T: Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
194{
195}
196
197/// Trait for validating and normalizing setting values.
198/// **Mandatory to implement** for all types used with `SettingsPlugin`.
199/// Called automatically:
200/// - after loading from a file (or `S::default()` if the file does not exist),
201/// - after `ReloadSetting`,
202/// - **before saving** (`PersistSetting` and `PersistAllSettings`),
203/// - **when a new value is provided** in `PersistSetting { value: Some(...) }`.
204///
205/// If validation is not needed, implement the method as empty.
206pub trait ValidatedSetting {
207    fn validate(&mut self);
208}
209
210// ================================
211// 5. Plugin Configuration
212// ================================
213
214/// Storage type for settings files.
215#[derive(Clone, Copy, PartialEq, Eq)]
216pub enum SettingsStorage {
217    /// System configuration directory:
218    /// - Windows: `%APPDATA%\Company\Project\config`
219    /// - macOS:   `~/Library/Application Support/company/project/config`
220    /// - Linux:   `~/.config/company/project/config`
221    SystemConfigDir,
222    /// Local directory where the game executable resides (next to the .exe).
223    GameLocalDir,
224}
225
226#[derive(Clone)]
227pub struct SettingsPluginConfig {
228    pub domain: String,
229    pub company: String,
230    pub project: String,
231    pub format: FormatKind,
232    pub file_name: Option<String>,
233    pub storage: SettingsStorage,
234}
235
236#[derive(Clone, Copy, PartialEq, Eq)]
237pub enum FormatKind {
238    Toml,
239    Json,
240    Binary,
241}
242
243impl SettingsPluginConfig {
244    /// Validates the configuration. Panics if:
245    /// - for `SystemConfigDir` `company` or `project` are empty, or `ProjectDirs` cannot be created
246    /// - for `GameLocalDir` no validation is performed (company/project may be empty)
247    pub fn validate(&self) {
248        match self.storage {
249            SettingsStorage::SystemConfigDir => {
250                if self.company.is_empty() {
251                    panic!(
252                        "SettingsPluginConfig: 'company' cannot be empty when using SystemConfigDir. \
253                         Please set a valid company name (e.g., 'MyCompany')."
254                    );
255                }
256                if self.project.is_empty() {
257                    panic!(
258                        "SettingsPluginConfig: 'project' cannot be empty when using SystemConfigDir. \
259                         Please set a valid project name (e.g., 'MyGame')."
260                    );
261                }
262                if ProjectDirs::from(&self.domain, &self.company, &self.project).is_none() {
263                    panic!(
264                        "SettingsPluginConfig: unable to determine standard config directory for domain='{}', company='{}', project='{}'. \
265                         Check that the strings do not contain invalid characters (e.g., '/', '\\', ':' on Windows).",
266                        self.domain, self.company, self.project
267                    );
268                }
269            }
270            SettingsStorage::GameLocalDir => {
271                // For local mode, company and project are not used; no validation needed.
272                // However, we can warn if they are empty (optional).
273                if self.company.is_empty() || self.project.is_empty() {
274                    bevy::log::warn!(
275                        "SettingsPluginConfig: 'company' or 'project' is empty while using GameLocalDir. \
276                         These fields are not required for local storage but may affect compatibility."
277                    );
278                }
279            }
280        }
281    }
282}
283
284impl Default for SettingsPluginConfig {
285    fn default() -> Self {
286        Self {
287            domain: "com".into(),
288            company: "".into(),
289            project: "".into(),
290            format: FormatKind::Toml,
291            file_name: None,
292            storage: SettingsStorage::SystemConfigDir,
293        }
294    }
295}
296
297// ================================
298// 6. Events (Bevy 0.18 Observers)
299// ================================
300
301#[derive(Event)]
302pub struct PersistAllSettings;
303
304#[derive(Event)]
305pub struct PersistSetting<S: Setting> {
306    pub value: Option<S>,
307}
308
309/// Event that triggers a reload of the settings from disk.
310///
311/// If the settings file does not exist or cannot be read, the current in‑memory resource is **not** changed.
312/// To reset to default values, use `PersistSetting` with `Some(S::default())` or implement your own logic.
313#[derive(Event)]
314pub struct ReloadSetting<S: Setting> {
315    pub _phantom: PhantomData<S>,
316}
317
318/// Event emitted when a settings save operation fails.
319#[derive(Event)]
320pub struct SettingsSaveError<S: Setting> {
321    pub error: SettingsError,
322    pub _phantom: PhantomData<S>,
323}
324
325// ================================
326// 7. Internal Resources
327// ================================
328
329#[derive(Resource)]
330struct SettingsInternal<S: Setting> {
331    config: SettingsPluginConfig,
332    path: PathBuf,
333    temp_path: PathBuf,
334    directory: PathBuf,
335    error_sender: mpsc::Sender<SettingsError>,
336    _marker: PhantomData<S>,
337}
338
339/// Resource for receiving errors from background threads.
340#[derive(Resource)]
341struct SettingsErrorReceiver<S: Setting> {
342    receiver: Mutex<mpsc::Receiver<SettingsError>>,
343    _marker: PhantomData<S>,
344}
345
346impl<S: Setting> SettingsInternal<S> {
347    fn new(
348        config: SettingsPluginConfig,
349        dir: PathBuf,
350        path: PathBuf,
351        error_sender: mpsc::Sender<SettingsError>,
352    ) -> Self {
353        let extension = match config.format {
354            FormatKind::Toml => TomlFormat::file_extension(),
355            FormatKind::Json => JsonFormat::file_extension(),
356            FormatKind::Binary => "bin",
357        };
358        Self {
359            temp_path: path.with_extension(format!("tmp.{}", extension)),
360            directory: dir,
361            path,
362            config,
363            error_sender,
364            _marker: PhantomData,
365        }
366    }
367}
368
369// ================================
370// 8. Main Plugin
371// ================================
372
373pub struct SettingsPlugin<S> {
374    config: SettingsPluginConfig,
375    _marker: PhantomData<S>,
376}
377
378impl<S: Setting + ValidatedSetting> SettingsPlugin<S> {
379    /// Creates a plugin from a ready‑made configuration.
380    /// Performs configuration validation (panics if `company` or `project` are invalid
381    /// when using `SystemConfigDir`).
382    pub fn from_config(config: SettingsPluginConfig) -> Self {
383        config.validate();
384        Self {
385            config,
386            _marker: PhantomData,
387        }
388    }
389
390    /// Returns the file name (without extension) based on the configuration.
391    fn file_stem(&self) -> String {
392        if let Some(ref name) = self.config.file_name {
393            name.clone()
394        } else {
395            // Convert the type name to "snake_case" and strip modules.
396            let type_name = std::any::type_name::<S>();
397            let short_name = type_name.split("::").last().unwrap_or(type_name);
398            let mut snake = String::new();
399            for (i, ch) in short_name.chars().enumerate() {
400                if ch.is_uppercase() && i > 0 {
401                    snake.push('_');
402                    snake.push(ch.to_ascii_lowercase());
403                } else {
404                    snake.push(ch.to_ascii_lowercase());
405                }
406            }
407            snake
408        }
409    }
410
411    /// Returns the base directory depending on the chosen storage.
412    fn base_dir(&self) -> PathBuf {
413        match self.config.storage {
414            SettingsStorage::SystemConfigDir => {
415                let proj_dirs = ProjectDirs::from(
416                    &self.config.domain,
417                    &self.config.company,
418                    &self.config.project,
419                )
420                .expect("Already validated in config");
421                proj_dirs.config_dir().to_path_buf()
422            }
423            SettingsStorage::GameLocalDir => {
424                let exe_path =
425                    std::env::current_exe().expect("Failed to get current executable path");
426                exe_path
427                    .parent()
428                    .expect("Executable has no parent directory")
429                    .to_path_buf()
430            }
431        }
432    }
433
434    fn load(&self) -> SettingsResult<S> {
435        let path = self.path();
436        let mut settings = if !path.exists() {
437            S::default()
438        } else {
439            match self.config.format {
440                FormatKind::Binary => read_binary(&path)?,
441                _ => {
442                    let data = std::fs::read_to_string(&path).map_err(SettingsError::Io)?;
443                    self.deserialize_text(&data)?
444                }
445            }
446        };
447        // Apply validation (if the type implements a custom method, it will be called).
448        settings.validate();
449        Ok(settings)
450    }
451
452    fn deserialize_text(&self, data: &str) -> SettingsResult<S> {
453        match self.config.format {
454            FormatKind::Toml => TomlFormat::deserialize(data),
455            FormatKind::Json => JsonFormat::deserialize(data),
456            FormatKind::Binary => unreachable!(),
457        }
458    }
459
460    fn serialize_text(&self, value: &S) -> SettingsResult<String> {
461        match self.config.format {
462            FormatKind::Toml => TomlFormat::serialize(value),
463            FormatKind::Json => JsonFormat::serialize(value),
464            FormatKind::Binary => unreachable!(),
465        }
466    }
467
468    fn path(&self) -> PathBuf {
469        let extension = match self.config.format {
470            FormatKind::Toml => TomlFormat::file_extension(),
471            FormatKind::Json => JsonFormat::file_extension(),
472            FormatKind::Binary => "bin",
473        };
474        let file_stem = self.file_stem();
475        self.base_dir().join(format!("{}.{}", file_stem, extension))
476    }
477
478    fn directory(&self) -> PathBuf {
479        self.base_dir()
480    }
481
482    // --- Helper function for saving ---
483    fn save_to_file(
484        temp_path: &Path,
485        path: &Path,
486        settings: &S,
487        format_kind: FormatKind,
488        error_sender: mpsc::Sender<SettingsError>,
489    ) {
490        let write_result = match format_kind {
491            FormatKind::Binary => write_binary(temp_path, settings),
492            _ => {
493                let content = match format_kind {
494                    FormatKind::Toml => TomlFormat::serialize(settings),
495                    FormatKind::Json => JsonFormat::serialize(settings),
496                    _ => unreachable!(),
497                };
498                match content {
499                    Ok(c) => std::fs::write(temp_path, c).map_err(SettingsError::Io),
500                    Err(e) => Err(e),
501                }
502            }
503        };
504
505        match write_result {
506            Ok(_) => {
507                if let Err(e) = std::fs::rename(temp_path, path) {
508                    bevy::log::error!("Failed to rename settings file: {}", e);
509                    let _ = error_sender.send(SettingsError::Io(e));
510                    // Remove the temporary file if rename failed.
511                    let _ = std::fs::remove_file(temp_path);
512                } else {
513                    bevy::log::debug!("Settings saved to {:?}", path);
514                }
515            }
516            Err(e) => {
517                bevy::log::error!("Failed to write temp settings file: {}", e);
518                let _ = error_sender.send(e);
519                // Try to delete the corrupted temporary file.
520                let _ = std::fs::remove_file(temp_path);
521            }
522        }
523    }
524
525    // --- Observers ---
526
527    fn persist_setting_observer(
528        event: On<PersistSetting<S>>,
529        mut settings: ResMut<S>,
530        internal: Res<SettingsInternal<S>>,
531    ) {
532        let ev = event.event();
533        if let Some(new_value) = &ev.value {
534            *settings = new_value.clone();
535            settings.validate();
536        }
537
538        settings.validate();
539
540        let path = internal.path.clone();
541        let temp_path = internal.temp_path.clone();
542        let settings_clone = settings.clone();
543        let format_kind = internal.config.format;
544        let error_sender = internal.error_sender.clone();
545
546        thread::Builder::new()
547            .name("bevy-settings-save".into())
548            .spawn(move || {
549                Self::save_to_file(
550                    &temp_path,
551                    &path,
552                    &settings_clone,
553                    format_kind,
554                    error_sender,
555                );
556            })
557            .expect("Failed to spawn save thread");
558    }
559
560    fn persist_all_observer(
561        _event: On<PersistAllSettings>,
562        mut settings: ResMut<S>,
563        internal: Res<SettingsInternal<S>>,
564    ) {
565        settings.validate();
566
567        let path = internal.path.clone();
568        let temp_path = internal.temp_path.clone();
569        let settings_clone = settings.clone();
570        let format_kind = internal.config.format;
571        let error_sender = internal.error_sender.clone();
572
573        thread::Builder::new()
574            .name("bevy-settings-save".into())
575            .spawn(move || {
576                Self::save_to_file(
577                    &temp_path,
578                    &path,
579                    &settings_clone,
580                    format_kind,
581                    error_sender,
582                );
583            })
584            .expect("Failed to spawn save thread");
585    }
586
587    fn reload_observer(
588        _event: On<ReloadSetting<S>>,
589        mut settings: ResMut<S>,
590        internal: Res<SettingsInternal<S>>,
591    ) {
592        let load_result = match internal.config.format {
593            FormatKind::Binary => read_binary::<S>(&internal.path),
594            _ => {
595                let content = match std::fs::read_to_string(&internal.path) {
596                    Ok(c) => c,
597                    Err(e) => {
598                        bevy::log::error!("Failed to read settings file: {}", e);
599                        return;
600                    }
601                };
602                match internal.config.format {
603                    FormatKind::Toml => TomlFormat::deserialize(&content),
604                    FormatKind::Json => JsonFormat::deserialize(&content),
605                    _ => unreachable!(),
606                }
607            }
608        };
609        match load_result {
610            Ok(mut new_settings) => {
611                new_settings.validate(); // validation after loading.
612                *settings = new_settings;
613                bevy::log::info!("Settings reloaded from {:?}", internal.path);
614            }
615            Err(e) => {
616                bevy::log::error!("Failed to reload settings: {}", e);
617            }
618        }
619    }
620
621    /// System that processes errors from background threads and sends them as events.
622    fn process_error_messages(
623        error_receiver: ResMut<SettingsErrorReceiver<S>>,
624        mut commands: Commands,
625    ) {
626        // Try to receive all pending errors without blocking
627        let receiver = error_receiver.receiver.lock().unwrap();
628        while let Ok(error) = receiver.try_recv() {
629            commands.trigger(SettingsSaveError::<S> {
630                error,
631                _phantom: PhantomData::<S>,
632            });
633        }
634    }
635}
636
637impl<S: Setting + ValidatedSetting> Plugin for SettingsPlugin<S> {
638    fn build(&self, app: &mut App) {
639        let load_result = self.load();
640        let mut initial_value = match load_result {
641            Ok(v) => v,
642            Err(e) => {
643                bevy::log::error!(
644                    "Failed to load settings for {}: {}, using default",
645                    std::any::type_name::<S>(),
646                    e
647                );
648                S::default()
649            }
650        };
651        // Validation (in case default already needs correction).
652        initial_value.validate();
653
654        let dir = self.directory();
655        let path = self.path();
656
657        // Create channel for error reporting from background threads
658        let (error_sender, error_receiver) = mpsc::channel();
659
660        // Attempt to create directory, send error through channel if fails
661        if let Err(e) = std::fs::create_dir_all(&dir) {
662            bevy::log::error!("Failed to create settings directory: {}", e);
663            let _ = error_sender.send(SettingsError::Io(e));
664        }
665
666        let internal = SettingsInternal::<S>::new(self.config.clone(), dir, path, error_sender);
667        let error_receiver_resource = SettingsErrorReceiver::<S> {
668            receiver: Mutex::new(error_receiver),
669            _marker: PhantomData,
670        };
671
672        app.insert_resource(initial_value)
673            .insert_resource(internal)
674            .insert_resource(error_receiver_resource)
675            .add_observer(Self::persist_setting_observer)
676            .add_observer(Self::persist_all_observer)
677            .add_observer(Self::reload_observer)
678            .add_systems(Update, Self::process_error_messages);
679    }
680}
681
682// ================================
683// 9. Test Utilities (tests only)
684// ================================
685
686#[cfg(test)]
687mod test_utils {
688    use super::*;
689    use std::path::PathBuf;
690
691    /// Returns `true` if test files should be cleaned up after tests (default).
692    /// Set the environment variable `KEEP_TEST_FILES=1` to disable cleanup.
693    pub fn should_cleanup() -> bool {
694        std::env::var("KEEP_TEST_FILES").is_err()
695    }
696
697    /// Deletes the given files or directories if `should_cleanup()` is true.
698    pub fn cleanup_paths(paths: &[PathBuf]) {
699        if !should_cleanup() {
700            println!("Skipping cleanup due to KEEP_TEST_FILES");
701            return;
702        }
703        for path in paths {
704            if path.exists() {
705                if path.is_file() {
706                    let _ = std::fs::remove_file(path);
707                } else if path.is_dir() {
708                    let _ = std::fs::remove_dir_all(path);
709                }
710            }
711        }
712    }
713
714    /// Returns the path to the configuration directory for the given `SettingsPluginConfig`.
715    /// For `GameLocalDir` in tests we use a temporary directory, because the real executable
716    /// path is unstable in the test environment.
717    pub fn config_dir(config: &SettingsPluginConfig) -> PathBuf {
718        match config.storage {
719            SettingsStorage::SystemConfigDir => {
720                ProjectDirs::from(&config.domain, &config.company, &config.project)
721                    .expect("Failed to determine config directory for test - check domain, company, and project values")
722                    .config_dir()
723                    .to_path_buf()
724            }
725            SettingsStorage::GameLocalDir => {
726                // For tests we use a temporary directory to avoid dependence on .exe location.
727                std::env::temp_dir()
728            }
729        }
730    }
731
732    pub fn settings_path<S: Setting + ValidatedSetting>(config: &SettingsPluginConfig) -> PathBuf {
733        let plugin = SettingsPlugin::<S>::from_config(config.clone());
734        plugin.path()
735    }
736}
737
738// ================================
739// 10. Tests
740// ================================
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use crate::test_utils::{cleanup_paths, config_dir, settings_path};
746    use bevy::app::App;
747    use serial_test::serial;
748    use std::collections::HashMap;
749    use std::time::Duration;
750
751    const TEST_DOMAIN: &str = "com";
752    const TEST_COMPANY: &str = "MyCompany";
753    const TEST_PROJECT: &str = "mygame";
754
755    #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
756    pub enum GraphicsQuality {
757        #[default]
758        Low,
759        Medium,
760        High,
761        Ultra,
762    }
763
764    #[derive(Resource, Serialize, Deserialize, Clone, Debug, PartialEq)]
765    struct GameConfig {
766        pub render_scale: f32,
767        pub max_fps: u16,
768        pub shadow_map_size: u32,
769        pub anisotropic_filtering: i32,
770        pub vsync_enabled: bool,
771        pub quality: GraphicsQuality,
772        pub default_language: String,
773        pub enabled_post_effects: Vec<String>,
774        pub custom_resolution: Option<(u32, u32)>,
775        pub texture_quality_overrides: HashMap<String, u8>,
776    }
777
778    impl Default for GameConfig {
779        fn default() -> Self {
780            Self {
781                render_scale: 1.0,
782                max_fps: 144,
783                shadow_map_size: 2048,
784                anisotropic_filtering: 16,
785                vsync_enabled: true,
786                quality: GraphicsQuality::High,
787                default_language: "en-US".to_string(),
788                enabled_post_effects: vec!["bloom".to_string(), "ssao".to_string()],
789                custom_resolution: None,
790                texture_quality_overrides: HashMap::new(),
791            }
792        }
793    }
794
795    impl ValidatedSetting for GameConfig {
796        fn validate(&mut self) {
797            self.render_scale = self.render_scale.clamp(0.5, 2.0);
798            self.max_fps = self.max_fps.clamp(30, 360);
799            self.shadow_map_size = self.shadow_map_size.clamp(512, 4096);
800            self.anisotropic_filtering = self.anisotropic_filtering.clamp(1, 16);
801            if self.default_language.is_empty() {
802                self.default_language = "en-US".to_string();
803            }
804            self.enabled_post_effects
805                .retain(|effect| matches!(effect.as_str(), "bloom" | "ssao" | "motion_blur"));
806            if let Some((w, h)) = self.custom_resolution {
807                if w == 0 || h == 0 {
808                    self.custom_resolution = None;
809                }
810            }
811            for (_, quality) in self.texture_quality_overrides.iter_mut() {
812                *quality = (*quality).clamp(0, 100);
813            }
814        }
815    }
816
817    #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
818    struct TestPreferences {
819        pub volume: f32,
820        pub size: f32,
821    }
822
823    // Mandatory implementation (empty if not needed).
824    impl ValidatedSetting for TestPreferences {
825        fn validate(&mut self) {}
826    }
827
828    #[test]
829    #[serial]
830    fn test_automatic_file_name() {
831        let mut app = App::new();
832
833        let base_config = SettingsPluginConfig {
834            domain: TEST_DOMAIN.into(),
835            company: TEST_COMPANY.into(),
836            project: TEST_PROJECT.into(),
837            format: FormatKind::Toml,
838            file_name: None,
839            storage: SettingsStorage::SystemConfigDir,
840        };
841
842        app.add_plugins(SettingsPlugin::<GameConfig>::from_config(
843            base_config.clone(),
844        ));
845        app.add_plugins(SettingsPlugin::<TestPreferences>::from_config(
846            base_config.clone(),
847        ));
848        app.update();
849
850        // Check name generation.
851        let plugin_game = SettingsPlugin::<GameConfig>::from_config(base_config.clone());
852        let plugin_prefs = SettingsPlugin::<TestPreferences>::from_config(base_config.clone());
853        assert_eq!(plugin_game.file_stem(), "game_config");
854        assert_eq!(plugin_prefs.file_stem(), "test_preferences");
855
856        // Modify values.
857        {
858            let mut game = app.world_mut().resource_mut::<GameConfig>();
859            game.render_scale = 1.2;
860            game.vsync_enabled = true;
861        }
862        {
863            let mut prefs = app.world_mut().resource_mut::<TestPreferences>();
864            prefs.volume = 0.75;
865            prefs.size = 1.5;
866        }
867
868        // Save.
869        app.world_mut()
870            .commands()
871            .trigger(PersistSetting::<GameConfig> { value: None });
872        app.world_mut()
873            .commands()
874            .trigger(PersistSetting::<TestPreferences> { value: None });
875        app.update();
876        std::thread::sleep(Duration::from_millis(100));
877
878        let dir = config_dir(&base_config);
879        let game_path = dir.join("game_config.toml");
880        let prefs_path = dir.join("test_preferences.toml");
881
882        assert!(game_path.exists(), "GameConfig file not found");
883        assert!(prefs_path.exists(), "TestPreferences file not found");
884
885        // Content verification.
886        let game_content = std::fs::read_to_string(&game_path).unwrap();
887        let loaded_game: GameConfig = toml::from_str(&game_content).unwrap();
888        assert_eq!(loaded_game.render_scale, 1.2);
889        assert_eq!(loaded_game.vsync_enabled, true);
890
891        let prefs_content = std::fs::read_to_string(&prefs_path).unwrap();
892        let loaded_prefs: TestPreferences = toml::from_str(&prefs_content).unwrap();
893        assert_eq!(loaded_prefs.volume, 0.75);
894        assert_eq!(loaded_prefs.size, 1.5);
895
896        // Cleanup (delete only files, leave directory untouched).
897        cleanup_paths(&[game_path, prefs_path]);
898    }
899
900    #[test]
901    #[serial]
902    fn test_explicit_file_name() {
903        let mut app = App::new();
904        let explicit_name = "explicit_name";
905        let config = SettingsPluginConfig {
906            domain: TEST_DOMAIN.into(),
907            company: TEST_COMPANY.into(),
908            project: TEST_PROJECT.into(),
909            format: FormatKind::Toml,
910            file_name: Some(explicit_name.into()),
911            storage: SettingsStorage::SystemConfigDir,
912        };
913        app.add_plugins(SettingsPlugin::<GameConfig>::from_config(config.clone()));
914        app.update();
915
916        let plugin = SettingsPlugin::<GameConfig>::from_config(config.clone());
917        assert_eq!(plugin.file_stem(), explicit_name);
918
919        {
920            let mut game = app.world_mut().resource_mut::<GameConfig>();
921            game.render_scale = 2.0;
922        }
923        app.world_mut()
924            .commands()
925            .trigger(PersistSetting::<GameConfig> { value: None });
926        app.update();
927        std::thread::sleep(Duration::from_millis(100));
928
929        let path = settings_path::<GameConfig>(&config);
930        assert!(path.exists(), "File does not exist at {:?}", path);
931
932        cleanup_paths(&[path]);
933    }
934}
935
936#[cfg(test)]
937mod comprehensive_tests {
938    use super::*;
939    use crate::test_utils::{cleanup_paths, settings_path};
940    use bevy::app::App;
941    use serial_test::serial;
942    use std::fs;
943    use std::time::Duration;
944
945    const TEST_DOMAIN: &str = "com";
946    const TEST_COMPANY: &str = "MyCompany";
947    const TEST_PROJECT: &str = "mygame";
948
949    #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
950    struct GameConfig2 {
951        pub render_scale: f32,
952        pub vsync: bool,
953    }
954
955    impl ValidatedSetting for GameConfig2 {
956        fn validate(&mut self) {
957            self.render_scale = self.render_scale.clamp(0.5, 2.0);
958        }
959    }
960
961    #[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
962    struct UserPrefs2 {
963        pub music_volume: f32,
964        pub sfx_volume: f32,
965        pub controls_inverted: bool,
966    }
967
968    impl ValidatedSetting for UserPrefs2 {
969        fn validate(&mut self) {
970            self.music_volume = self.music_volume.clamp(0.0, 1.0);
971            self.sfx_volume = self.sfx_volume.clamp(0.0, 1.0);
972        }
973    }
974
975    #[test]
976    #[serial]
977    fn test_config_and_prefs_together() {
978        let mut app = App::new();
979
980        let config_game = SettingsPluginConfig {
981            domain: TEST_DOMAIN.into(),
982            company: TEST_COMPANY.into(),
983            project: TEST_PROJECT.into(),
984            format: FormatKind::Toml,
985            file_name: None,
986            storage: SettingsStorage::SystemConfigDir,
987        };
988
989        let user_prefs = SettingsPluginConfig {
990            domain: TEST_DOMAIN.into(),
991            company: TEST_COMPANY.into(),
992            project: TEST_PROJECT.into(),
993            format: FormatKind::Json,
994            file_name: None,
995            storage: SettingsStorage::GameLocalDir,
996        };
997
998        app.add_plugins(SettingsPlugin::<GameConfig2>::from_config(
999            config_game.clone(),
1000        ));
1001        app.add_plugins(SettingsPlugin::<UserPrefs2>::from_config(
1002            user_prefs.clone(),
1003        ));
1004
1005        // Modify values.
1006        {
1007            let mut config = app.world_mut().resource_mut::<GameConfig2>();
1008            config.render_scale = 1.5;
1009            config.vsync = true;
1010        }
1011        {
1012            let mut prefs = app.world_mut().resource_mut::<UserPrefs2>();
1013            prefs.music_volume = 0.8;
1014            prefs.sfx_volume = 0.9;
1015            prefs.controls_inverted = true;
1016        }
1017
1018        // Save.
1019        app.world_mut()
1020            .commands()
1021            .trigger(PersistSetting::<GameConfig2> { value: None });
1022        app.world_mut()
1023            .commands()
1024            .trigger(PersistSetting::<UserPrefs2> { value: None });
1025        for _ in 0..10 {
1026            app.update();
1027            std::thread::sleep(Duration::from_millis(50));
1028        }
1029
1030        let game_path = settings_path::<GameConfig2>(&config_game);
1031        let prefs_path = settings_path::<UserPrefs2>(&user_prefs);
1032
1033        assert!(game_path.exists(), "Config file not found");
1034        assert!(prefs_path.exists(), "Prefs file not found");
1035
1036        // Content verification.
1037        let game_content = fs::read_to_string(&game_path).unwrap();
1038        let loaded_config: GameConfig2 = toml::from_str(&game_content).unwrap();
1039        assert_eq!(loaded_config.render_scale, 1.5);
1040        assert_eq!(loaded_config.vsync, true);
1041
1042        let prefs_content = fs::read_to_string(&prefs_path).unwrap();
1043        let loaded_prefs: UserPrefs2 = serde_json::from_str(&prefs_content).unwrap();
1044        assert_eq!(loaded_prefs.music_volume, 0.8);
1045        assert_eq!(loaded_prefs.sfx_volume, 0.9);
1046        assert_eq!(loaded_prefs.controls_inverted, true);
1047
1048        // Simulate external change with invalid values.
1049        let new_config_content = r#"
1050            render_scale = 10.0
1051            vsync = false
1052        "#;
1053        fs::write(&game_path, new_config_content).unwrap();
1054
1055        let new_prefs_content =
1056            r#"{ "music_volume": 2.0, "sfx_volume": 1.5, "controls_inverted": false }"#;
1057        fs::write(&prefs_path, new_prefs_content).unwrap();
1058
1059        // Reload.
1060        app.world_mut()
1061            .commands()
1062            .trigger(ReloadSetting::<GameConfig2> {
1063                _phantom: PhantomData,
1064            });
1065        app.world_mut()
1066            .commands()
1067            .trigger(ReloadSetting::<UserPrefs2> {
1068                _phantom: PhantomData,
1069            });
1070        for _ in 0..5 {
1071            app.update();
1072            std::thread::sleep(Duration::from_millis(50));
1073        }
1074
1075        let config = app.world().resource::<GameConfig2>();
1076        assert_eq!(config.render_scale, 2.0); // clamped
1077        assert_eq!(config.vsync, false);
1078
1079        let prefs = app.world().resource::<UserPrefs2>();
1080        assert_eq!(prefs.music_volume, 1.0); // clamped
1081        assert_eq!(prefs.sfx_volume, 1.0); // clamped
1082        assert_eq!(prefs.controls_inverted, false);
1083
1084        // Cleanup.
1085        cleanup_paths(&[game_path, prefs_path]);
1086    }
1087}