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