bevy_settings/
lib.rs

1extern crate directories;
2
3use std::{fmt::Debug, marker::PhantomData, path::PathBuf};
4
5use directories::ProjectDirs;
6
7use bevy_app::{App, Plugin, Update};
8use bevy_ecs::{
9    prelude::{Event, EventReader, Resource},
10    system::Res,
11};
12use bevy_log::prelude::debug;
13
14pub extern crate serde;
15pub use serde::{Deserialize, Serialize};
16
17/// this is a blanked trait
18pub trait Settingable: Resource + Clone + Serialize + Default + for<'a> Deserialize<'a> {}
19
20/// this is a blanked implementation of [`Settingable`]
21impl<S> Settingable for S where S: Resource + Clone + Serialize + Default + for<'a> Deserialize<'a> {}
22
23/// This will persist all structs that
24/// are added via the Plugin [`SettingsPlugin`]
25#[derive(Event)]
26pub struct PersistSettings;
27
28/// This will persist structs S that
29/// was added via the Plugin [`SettingsPlugin`]
30#[derive(Event, Default)]
31pub struct PersistSetting<S: Settingable>(PhantomData<S>);
32
33pub struct SettingsPlugin<S: Settingable> {
34    domain: String,
35    company: String,
36    project: String,
37    settings: PhantomData<S>,
38}
39
40#[derive(Resource, Debug)]
41pub struct SettingsConfig<S: Settingable> {
42    directory: PathBuf,
43    path: PathBuf,
44    settings: PhantomData<S>,
45}
46
47impl<S: Settingable> SettingsPlugin<S> {
48    pub fn new(company: impl Into<String>, project: impl Into<String>) -> Self {
49        Self {
50            domain: "com".into(),
51            company: company.into(),
52            project: project.into(),
53            settings: PhantomData::<S>,
54        }
55    }
56
57    pub fn resource(&self) -> S {
58        self.load().unwrap_or_default()
59    }
60
61    fn load(&self) -> Option<S> {
62        let path = self.path();
63        if !path.exists() {
64            return None;
65        }
66        let settings_string = std::fs::read_to_string(path).ok()?;
67        toml::from_str(&settings_string).ok()
68    }
69
70    fn path(&self) -> PathBuf {
71        ProjectDirs::from(&self.domain, &self.company, &self.project)
72            .expect("Couldn't build settings path")
73            .config_dir()
74            .join(format!("{}.toml", self.project))
75    }
76
77    fn settings_directory(&self) -> PathBuf {
78        ProjectDirs::from(&self.domain, &self.company, &self.project)
79            .expect("Couldn't find a folder to store the settings")
80            .config_dir()
81            .to_path_buf()
82    }
83
84    fn persist(
85        settings: Res<S>,
86        config: Res<SettingsConfig<S>>,
87        reader_single: EventReader<PersistSetting<S>>,
88        reader_all: EventReader<PersistSettings>,
89    ) {
90        if !reader_single.is_empty() || !reader_all.is_empty() {
91            match (!reader_single.is_empty(), !reader_all.is_empty()) {
92                (true, true) => debug!(
93                    "Persist called on {} to path {:?} and also through the global event",
94                    std::any::type_name::<S>(),
95                    config.path
96                ),
97                (true, false) => debug!(
98                    "Persist called through global event to path {:?}",
99                    config.path
100                ),
101                (false, true) => debug!(
102                    "Persist called on {} to path {:?}",
103                    std::any::type_name::<S>(),
104                    config.path
105                ),
106                (false, false) => {}
107            }
108
109            std::fs::create_dir_all(config.directory.clone())
110                .expect("Couldn't create the folders for the settings file");
111            std::fs::write(
112                config.path.clone(),
113                toml::to_string(&*settings).expect("Couldn't serialize the settings to toml"),
114            )
115            .expect("couldn't persist the settings while trying to write the string to disk");
116        }
117    }
118}
119
120impl<S: Settingable> Plugin for SettingsPlugin<S> {
121    fn build(&self, app: &mut App) {
122        app.insert_resource(self.resource())
123            .insert_resource(SettingsConfig {
124                directory: self.settings_directory(),
125                path: self.path(),
126                settings: PhantomData::<S>,
127            })
128            .add_event::<PersistSettings>()
129            .add_event::<PersistSetting<S>>()
130            .add_systems(Update, SettingsPlugin::<S>::persist);
131    }
132}
133
134/// tests need to run in serial
135/// since they consume the filesystem
136/// on an user basis
137#[cfg(test)]
138mod tests {
139    use super::{PersistSettings, SettingsPlugin};
140    use bevy::prelude::*;
141    use pretty_assertions::{assert_eq, assert_ne};
142
143    use crate::PersistSetting;
144    pub use crate::{Deserialize, Serialize};
145
146    #[derive(Resource, Default, Serialize, Deserialize, Clone)]
147    struct TestSetting1 {
148        test: u32,
149    }
150
151    #[derive(Resource, Default, Serialize, Deserialize, Clone)]
152    struct TestSetting2 {
153        test: u32,
154    }
155
156    #[test]
157    #[serial_test::serial]
158    fn it_should_store_multiple_settings() {
159        let mut app1 = App::new();
160        let u32_1: u32 = rand::random::<u32>();
161        let u32_2: u32 = rand::random::<u32>();
162        app1.add_plugins(SettingsPlugin::<TestSetting1>::new(
163            "Bevy Settings Test Corp",
164            "Some Game File 1",
165        ));
166        app1.add_plugins(SettingsPlugin::<TestSetting2>::new(
167            "Bevy Settings Test Corp",
168            "Some Game File 2",
169        ));
170        app1.add_systems(
171            Update,
172            move |mut writer: EventWriter<PersistSettings>,
173                  mut test_setting_1: ResMut<TestSetting1>,
174                  mut test_setting_2: ResMut<TestSetting2>| {
175                *test_setting_1 = TestSetting1 { test: u32_1 };
176                *test_setting_2 = TestSetting2 { test: u32_2 };
177                writer.write(PersistSettings);
178            },
179        );
180        app1.update(); // send event
181        app1.update(); // react to persist
182
183        let mut app2 = App::new();
184        app2.add_plugins(SettingsPlugin::<TestSetting1>::new(
185            "Bevy Settings Test Corp",
186            "Some Game File 1",
187        ));
188        app2.add_plugins(SettingsPlugin::<TestSetting2>::new(
189            "Bevy Settings Test Corp",
190            "Some Game File 2",
191        ));
192        app2.update();
193        let world2 = app2.world();
194        let test_setting_1 = world2.resource::<TestSetting1>();
195        assert_eq!(test_setting_1.test, u32_1, "Failed to verify TestSetting1");
196        let test_setting_2 = world2.resource::<TestSetting2>();
197        assert_eq!(test_setting_2.test, u32_2, "Failed to verify TestSetting2");
198    }
199
200    #[test]
201    #[serial_test::serial]
202    fn it_should_store_singular_settings() {
203        let mut app1 = App::new();
204        let u32_1: u32 = rand::random::<u32>();
205        let u32_2: u32 = rand::random::<u32>();
206        app1.add_plugins(SettingsPlugin::<TestSetting1>::new(
207            "Bevy Settings Test Corp",
208            "Some Game File 1",
209        ));
210        app1.add_plugins(SettingsPlugin::<TestSetting2>::new(
211            "Bevy Settings Test Corp",
212            "Some Game File 2",
213        ));
214        app1.add_systems(
215            Update,
216            move |mut writer: EventWriter<PersistSetting<TestSetting1>>,
217                  mut test_setting_1: ResMut<TestSetting1>,
218                  mut test_setting_2: ResMut<TestSetting2>| {
219                *test_setting_1 = TestSetting1 { test: u32_1 };
220                *test_setting_2 = TestSetting2 { test: u32_2 };
221                writer.write(PersistSetting::default());
222            },
223        );
224        app1.update(); // send event
225        app1.update(); // react to persist
226
227        let mut app2 = App::new();
228        app2.add_plugins(SettingsPlugin::<TestSetting1>::new(
229            "Bevy Settings Test Corp",
230            "Some Game File 1",
231        ));
232        app2.add_plugins(SettingsPlugin::<TestSetting2>::new(
233            "Bevy Settings Test Corp",
234            "Some Game File 2",
235        ));
236        app2.update();
237        let world2 = app2.world();
238        let test_setting_1 = world2.resource::<TestSetting1>();
239        assert_eq!(test_setting_1.test, u32_1, "Failed to verify TestSetting1");
240        let test_setting_2 = world2.resource::<TestSetting2>();
241        assert_ne!(test_setting_2.test, u32_2, "Failed to verify TestSetting2");
242    }
243}