1use std::io::ErrorKind;
32use std::path::{Path, PathBuf};
33
34use bevy_app::prelude::*;
35use bevy_ecs::prelude::*;
36use bevy_log::warn;
37use serde::de::DeserializeOwned;
38
39use crate::services::{ProjectRoot, RigHome};
40use crate::workspace::ProjectWorkspace;
41
42#[derive(Resource, Clone, Debug)]
45pub struct Settings {
46 file: String,
47 tables: toml::Table,
48 problems: Vec<String>,
49}
50
51impl Default for Settings {
52 fn default() -> Self {
54 Self::empty("settings.toml".to_owned())
55 }
56}
57
58impl Settings {
59 pub fn from_toml(text: &str) -> Self {
62 Self::parse("settings.toml".to_owned(), text)
63 }
64
65 pub fn read(path: &Path) -> Self {
68 let file = path.display().to_string();
69 match std::fs::read_to_string(path) {
70 Ok(text) => Self::parse(file, &text),
71 Err(failure) if failure.kind() == ErrorKind::NotFound => Self::empty(file),
72 Err(failure) => {
73 let problem = format!("{file}: could not read: {failure}");
74 Self {
75 problems: vec![problem],
76 ..Self::empty(file)
77 }
78 }
79 }
80 }
81
82 fn empty(file: String) -> Self {
84 Self {
85 file,
86 tables: toml::Table::new(),
87 problems: Vec::new(),
88 }
89 }
90
91 fn parse(file: String, text: &str) -> Self {
93 match text.parse::<toml::Table>() {
94 Ok(tables) => Self {
95 tables,
96 ..Self::empty(file)
97 },
98 Err(failure) => {
99 let problem = format!("{file}: not TOML: {failure}");
100 Self {
101 problems: vec![problem],
102 ..Self::empty(file)
103 }
104 }
105 }
106 }
107
108 pub fn get<T: DeserializeOwned + Default>(&self, section: &str) -> Result<T, String> {
111 let Some(table) = self.tables.get(section) else {
112 return Ok(T::default());
113 };
114 table
115 .clone()
116 .try_into()
117 .map_err(|failure| format!("{} [{section}]: {failure}", self.file))
118 }
119
120 pub fn problems(&self) -> &[String] {
123 &self.problems
124 }
125}
126
127pub trait AppSettingsExt {
129 fn add_setting<T: Resource + DeserializeOwned + Default>(&mut self, section: &str)
134 -> &mut Self;
135
136 fn add_project_setting<T: Resource + DeserializeOwned + Default>(
161 &mut self,
162 section: &str,
163 ) -> &mut Self;
164}
165
166impl AppSettingsExt for App {
167 fn add_setting<T: Resource + DeserializeOwned + Default>(
168 &mut self,
169 section: &str,
170 ) -> &mut Self {
171 let world = self.world_mut();
172 if !world.contains_resource::<Settings>() {
173 let settings = Settings::read(&RigHome::of(world).settings());
174 world.insert_resource(settings);
175 }
176 let mut settings = world.resource_mut::<Settings>();
177 let value = settings.get::<T>(section).unwrap_or_else(|problem| {
178 warn!("{problem}");
179 settings.problems.push(problem);
180 T::default()
181 });
182 self.insert_resource(value)
183 }
184
185 fn add_project_setting<T: Resource + DeserializeOwned + Default>(
186 &mut self,
187 section: &str,
188 ) -> &mut Self {
189 self.add_setting::<T>(section);
190 if !self.world().contains_resource::<ProjectSettings>() {
191 self.init_resource::<ProjectSettings>()
192 .add_systems(PreStartup, read_project_settings);
193 }
194 self.world_mut()
195 .resource_mut::<ProjectSettings>()
196 .0
197 .push((section.to_owned(), insert_merged::<T>));
198 self
199 }
200}
201
202#[derive(Resource, Default)]
204struct ProjectSettings(Vec<(String, Insert)>);
205
206type Insert = fn(&mut World, toml::Value) -> Result<(), String>;
208
209fn insert_merged<T: Resource + DeserializeOwned + Default>(
211 world: &mut World,
212 table: toml::Value,
213) -> Result<(), String> {
214 let value = table
215 .try_into::<T>()
216 .map_err(|failure| failure.to_string())?;
217 world.insert_resource(value);
218 Ok(())
219}
220
221fn read_project_settings(world: &mut World) {
224 let root = world
225 .get_resource::<ProjectRoot>()
226 .map_or_else(|| ProjectRoot::default().0, |root| root.0.clone());
227 let workspace = world
228 .get_resource::<ProjectWorkspace>()
229 .cloned()
230 .unwrap_or_default();
231 let path: PathBuf = root.join(".rig").join("settings.toml");
232 let file = path.display().to_string();
233 let project = match workspace.0.read(&path, rig_tools::MAX_FILE_BYTES) {
234 Ok(contents) => Settings::parse(file, &String::from_utf8_lossy(&contents.bytes)),
235 Err(failure) if failure.kind() == ErrorKind::NotFound => return,
236 Err(failure) => Settings {
237 problems: vec![format!("{file}: could not read: {failure}")],
238 ..Settings::empty(file)
239 },
240 };
241 let declared = world
242 .get_resource::<ProjectSettings>()
243 .map(|it| it.0.clone())
244 .unwrap_or_default();
245 let mut problems = project.problems.clone();
246 for (section, insert) in declared {
247 let Some(over) = project.tables.get(§ion) else {
248 continue;
249 };
250 let user = world
251 .get_resource::<Settings>()
252 .and_then(|settings| settings.tables.get(§ion).cloned());
253 let merged = match user {
254 Some(user) => merge(user, over.clone()),
255 None => over.clone(),
256 };
257 if let Err(failure) = insert(world, merged) {
258 problems.push(format!("{} [{section}]: {failure}", project.file));
259 }
260 }
261 for problem in &problems {
262 warn!("{problem}");
263 }
264 if let Some(mut settings) = world.get_resource_mut::<Settings>() {
265 settings.problems.extend(problems);
266 }
267}
268
269fn merge(base: toml::Value, over: toml::Value) -> toml::Value {
272 match (base, over) {
273 (toml::Value::Table(mut base), toml::Value::Table(over)) => {
274 for (key, value) in over {
275 let merged = match base.remove(&key) {
276 Some(was) => merge(was, value),
277 None => value,
278 };
279 base.insert(key, merged);
280 }
281 toml::Value::Table(base)
282 }
283 (_, over) => over,
284 }
285}
286
287#[cfg(test)]
288mod tests;